Compare commits

...

35 Commits

Author SHA1 Message Date
MengMengCode 5207082cd1 release: v1.1.4 2026-06-08 15:44:58 +08:00
MengMengCode 608b50f18a 修复了一些功能 2026-06-08 15:44:35 +08:00
MengMengCode b58a6b1030 release: v1.1.3 2026-06-08 14:41:51 +08:00
MengMengCode 366f889a8c 优化安装脚本执行逻辑 2026-06-08 14:40:06 +08:00
MengMengCode 814441e9a0 release: v1.1.2 2026-06-08 02:24:04 +08:00
MengMengCode aed11af105 修复了一些已知问题 2026-06-08 02:23:48 +08:00
MengMengCode 3d95bb33c1 Merge branch 'main' of https://github.com/MengMengCode/CLICD 2026-06-08 01:21:12 +08:00
MengMengCode ade1c6c093 优化功能体验 2026-06-08 01:21:10 +08:00
Meng Meng 5c4cc1cab3 Merge pull request #4 from MengMengCode/dependabot/npm_and_yarn/frontend/vite-8.0.16
build(deps-dev): bump vite from 5.4.21 to 8.0.16 in /frontend
2026-06-07 23:42:42 +08:00
copilot-swe-agent[bot] 109e47170f fix: resolve frontend dependency conflicts for Vite 8 build 2026-06-07 15:40:09 +00:00
dependabot[bot] 34637cc79d build(deps-dev): bump vite from 5.4.21 to 8.0.16 in /frontend
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 5.4.21 to 8.0.16.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.0.16/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 8.0.16
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-07 15:31:31 +00:00
MengMengCode 7d48889eea release: v1.1.1 2026-06-07 23:29:06 +08:00
MengMengCode 2bcdb9e095 Merge branch 'main' of https://github.com/MengMengCode/CLICD 2026-06-07 23:10:21 +08:00
MengMengCode 2ab42e7f57 支持部署KVM XFCE桌面系统 2026-06-07 23:10:19 +08:00
Meng Meng 3257cbb2a3 Merge pull request #2 from MengMengCode/dependabot/go_modules/backend/golang.org/x/crypto-0.45.0
build(deps): bump golang.org/x/crypto from 0.28.0 to 0.45.0 in /backend
2026-06-07 22:06:55 +08:00
dependabot[bot] 1ff5d7a85e build(deps): bump golang.org/x/crypto from 0.28.0 to 0.45.0 in /backend
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.28.0 to 0.45.0.
- [Commits](https://github.com/golang/crypto/compare/v0.28.0...v0.45.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-version: 0.45.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-07 13:29:47 +00:00
Meng Meng a99781d418 Merge pull request #1 from MengMengCode/dependabot/go_modules/backend/github.com/golang-jwt/jwt/v5-5.2.2
Bump github.com/golang-jwt/jwt/v5 from 5.2.1 to 5.2.2 in /backend
2026-06-07 21:27:51 +08:00
MengMengCode b993e57d05 fix: repair login page encoding 2026-06-07 21:19:24 +08:00
MengMengCode 7ae0c91813 release: v1.1.0 2026-06-07 21:14:31 +08:00
MengMengCode aab58aca6e release: v1.1.0 2026-06-07 21:11:53 +08:00
MengMengCode 0b27604f95 FIX 2026-06-07 21:11:29 +08:00
dependabot[bot] 460614e274 Bump github.com/golang-jwt/jwt/v5 from 5.2.1 to 5.2.2 in /backend
Bumps [github.com/golang-jwt/jwt/v5](https://github.com/golang-jwt/jwt) from 5.2.1 to 5.2.2.
- [Release notes](https://github.com/golang-jwt/jwt/releases)
- [Commits](https://github.com/golang-jwt/jwt/compare/v5.2.1...v5.2.2)

---
updated-dependencies:
- dependency-name: github.com/golang-jwt/jwt/v5
  dependency-version: 5.2.2
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-07 13:11:00 +00:00
MengMengCode 007811ab41 release: v1.1.0 2026-06-07 21:09:37 +08:00
MengMengCode 95af3e44f2 优化了一些功能 2026-06-07 21:08:51 +08:00
MengMengCode 2ad17fa520 修复了一些已知问题 2026-06-07 20:16:00 +08:00
MengMengCode 08a1a057e7 Update Disclaimer 2026-06-07 19:09:23 +08:00
MengMengCode e9f657ab17 release: v1.0.13 2026-06-07 18:58:10 +08:00
MengMengCode 7e5da67de4 兼容KVM大多数功能 2026-06-07 18:57:55 +08:00
MengMengCode c99f3f6d55 release: v1.0.12 2026-06-07 13:16:39 +08:00
MengMengCode 2df92be501 修复了一些已知问题 2026-06-07 13:15:59 +08:00
MengMengCode f8d16ca792 添加标签 2026-06-07 10:55:52 +08:00
MengMengCode be17f669f7 release: v1.0.11 2026-06-07 10:48:40 +08:00
MengMengCode 65fc787070 优化了一些功能 2026-06-07 10:48:25 +08:00
MengMengCode 245c57449c 初步支持KVM 2026-06-07 09:24:21 +08:00
MengMengCode 6dd7079e23 初步支持KVM 2026-06-07 09:24:09 +08:00
57 changed files with 9353 additions and 2177 deletions
Submodule .claude/worktrees/agent-ae3871aebda20eb86 added at 422e48b524
+3
View File
@@ -58,9 +58,12 @@ backend/tmp/
*.swp *.swp
*.swo *.swo
*~ *~
*.claude/
# OS # OS
.DS_Store .DS_Store
Thumbs.db Thumbs.db
linux.txt linux.txt
push-release.ps1 push-release.ps1
deploy.ps1
backend/clicd
+33 -11
View File
@@ -11,28 +11,28 @@
<img alt="Vite" src="https://img.shields.io/badge/Vite-5-646CFF?style=flat-square&logo=vite&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="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="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">
</p> </p>
CLICD 是一个面向 LXC 的轻量容器管理面板,提供 Web 控制台、CLI、批量任务、镜像管理、NAT 端口、IPv6 分配、WebSSH、资源限制、流量限制和安全告警能力。它适合用来管理小型 VPS 上的 LXC 容器,也适合需要批量创建和分发子用户管理链接的场景。 CLICD 是一个面向 LXC/KVM 的轻量虚拟化管理面板,提供 Web 控制台、CLI、批量任务、镜像管理、NAT 端口、IPv6 分配、WebSSH、VNC、资源限制、流量限制和安全告警能力。它适合用来管理小型 VPS 上的 LXC 容器和 KVM 虚拟机,也适合需要批量创建和分发子用户管理链接的场景。
## 功能介绍 ## 功能介绍
1. 支持 Ubuntu、Debian、Alpine、CentOS、Arch Linux、Fedora、Rocky Linux 等系统镜像。镜像可以在镜像管理中按需下载;如果宿主机资源比较小,建议优先选择 Alpine 这类轻量镜像。 1. 支持 Ubuntu、Debian、Alpine、CentOS、Arch Linux、Fedora、Rocky Linux 等系统镜像。镜像可以在镜像管理中按需下载;如果宿主机资源比较小,建议优先选择 Alpine 这类轻量镜像。
2. 支持 WebSSH 管理,可以在浏览器里一键进入容器终端,不需要手动复制 SSH 密码。 2. 支持 WebSSH 管理,可以在浏览器里一键进入容器终端,不需要手动复制 SSH 密码。
3. 支持子用户管理链接,管理员可以把指定容器分发给拼车用户,子用户只能管理自己被授权的容器 3. 支持设置 NAT4 端口数量、NAT 端口映射和协议限制,并支持分配公网 IPv6。IPv6 分配要求宿主机本身拥有可路由的 IPv6 地址段
4. 支持设置 NAT4 端口数量、NAT 端口映射和协议限制,并支持分配公网 IPv6。IPv6 分配要求宿主机本身拥有可路由的 IPv6 地址段 4. 支持单向和双向网络流量限制。达到限制后容器会自动关机,避免流量超额
5. 支持超售容量估算。宿主机控制页提供 KSM 合并、Swap 倾向和 cgroup v2 `memory.reclaim` 一次性回收能力;不会展示 LXC 下无实际通用效果的内存气球回收开关 5. 支持设置容器有效期。到期后容器会自动关机,子用户无法继续操作,只有管理员重新设置延期日期后才能恢复使用
6. 支持 API 接入,可以通过 API 完成容器、任务、镜像、端口、流量、安全告警等功能的自动化控制 6. 内置基于 conntrack 的轻量安全告警。系统不会保存完整正常连接日志,但会对端口扫描、横向扫描、爆破倾向、SMTP 滥用、UDP 反射、挖矿端口、代理/VPN/Tor 等可疑行为生成告警并写入审计日志
7. 支持仅使用 CLI 管理。需要关闭 Web 控制台时,可以停止并禁用 systemd 服务,然后使用 `clicd cli --no-web` 进入命令行模式 7. 支持子用户管理链接,管理员可以把指定容器分发给拼车用户,子用户只能管理自己被授权的容器
8. 支持设置容器有效期。到期后容器会自动关机,子用户无法继续操作,只有管理员重新设置延期日期后才能恢复使用 8. 支持 API 接入,可以通过 API 完成容器、任务、镜像、端口、流量、安全告警等功能的自动化控制
9. 支持单向和双向网络流量限制。达到限制后容器会自动关机,避免流量超额 9. 支持仅使用 CLI 管理。需要关闭 Web 控制台时,可以停止并禁用 systemd 服务,然后使用 `clicd cli --no-web` 进入命令行模式
10. 内置基于 conntrack 的轻量安全告警。系统不会保存完整正常连接日志,但会对端口扫描、横向扫描、爆破倾向、SMTP 滥用、UDP 反射、挖矿端口、代理/VPN/Tor 等可疑行为生成告警并写入审计日志。
## 技术栈 ## 技术栈
- Backend: Go, net/http, LXC, cgroup v2, iptables, conntrack - Backend: Go, net/http, LXC, KVM/libvirt, cgroup v2, iptables, conntrack
- Frontend: React, TypeScript, Vite, Tailwind CSS, lucide-react, xterm.js - Frontend: React, TypeScript, Vite, Tailwind CSS, lucide-react, xterm.js
- Runtime: Linux, systemd, LXC - Runtime: Linux, systemd, LXC, KVM/QEMU
- Build: GitHub Actions, Node.js 20, Go 1.22 - Build: GitHub Actions, Node.js 20, Go 1.22
## 安装 ## 安装
@@ -53,6 +53,28 @@ curl -fsSL https://raw.githubusercontent.com/MengMengCode/CLICD/main/install.sh
![alt text](/img/image-1.png) ![alt text](/img/image-1.png)
![alt text](/img/image-2.png) ![alt text](/img/image-2.png)
## Disclaimer/免责声明
This open-source software does not distribute Windows system images, nor does it provide any means to bypass or circumvent Windows activation mechanisms.
All download links provided within the software point to resources officially supplied by Microsoft. Users of this software are responsible for obtaining the appropriate licenses from Microsoft before using any Windows operating system downloaded through these links. This project does not bypass activation requirements for installed systems, nor does it assume any responsibility for the consequences of users' actions when using this software.
This open-source software is intended solely for educational purposes, specifically for learning the principles of LXC and KVM. The copyright for the Windows logo and related icons belongs to Microsoft/Windows.
本开源软件不提供任何 Windows 操作系统镜像的分发服务,也不包含任何绕过、破解或免除 Windows 激活机制的功能。
软件内涉及的 Windows 系统下载链接均由微软官方提供。使用者在下载、安装和使用相关 Windows 系统时,应自行向微软或其授权渠道购买并获得相应的软件许可。本项目不会对安装后的 Windows 系统进行任何形式的激活绕过、破解或免激活处理。
对于使用者因使用本软件而产生的任何行为及其后果,包括但不限于软件许可、系统使用、数据丢失、法律责任或其他相关问题,本项目及其开发者不承担任何责任。
本开源软件仅供学习和研究 LXC、KVM 等虚拟化技术原理之目的使用,不得用于任何违反适用法律法规、软件许可协议或第三方权益的行为。
本软件中涉及的 Windows 名称、标识、图标及相关知识产权均归 Microsoft Corporation 及其权利人所有。本项目与微软公司不存在任何关联、授权或合作关系。
## Thanks/鸣谢
- [Linux.do](https://linux.do) — 一个充满灵感的科技社区
## Star History ## Star History
<a href="https://www.star-history.com/?repos=MengMengCode%2FCLICD&type=date&legend=top-left"> <a href="https://www.star-history.com/?repos=MengMengCode%2FCLICD&type=date&legend=top-left">
+26 -5
View File
@@ -1,12 +1,33 @@
module clicd module clicd
go 1.22.0 go 1.24.0
toolchain go1.24.5
require ( require (
github.com/golang-jwt/jwt/v5 v5.2.1 github.com/golang-jwt/jwt/v5 v5.2.2
github.com/gorilla/websocket v1.5.3 github.com/gorilla/websocket v1.5.3
golang.org/x/crypto v0.28.0 golang.org/x/crypto v0.45.0
golang.org/x/term v0.28.0 golang.org/x/term v0.37.0
) )
require golang.org/x/sys v0.29.0 // indirect require (
golang.org/x/sys v0.38.0
modernc.org/sqlite v1.29.10
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/exp v0.0.0-20231108232855-2478ac86f678 // indirect
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
modernc.org/libc v1.61.13 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.8.2 // indirect
modernc.org/strutil v1.2.1 // indirect
modernc.org/token v1.1.0 // indirect
)
+59 -8
View File
@@ -1,10 +1,61 @@
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek= github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/exp v0.0.0-20231108232855-2478ac86f678 h1:mchzmB1XO2pMaKFRqk/+MV3mgGG96aqaPXaMifQU47w=
golang.org/x/exp v0.0.0-20231108232855-2478ac86f678/go.mod h1:zk2irFbV9DP96SEBUUAy67IdHUaZuSnrz1n472HUCLE=
golang.org/x/mod v0.19.0 h1:fEdghXQSo20giMthA7cd28ZC+jts4amQ3YMXiP5oMQ8=
golang.org/x/mod v0.19.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
golang.org/x/tools v0.23.0 h1:SGsXPZ+2l4JsgaCKkx+FQ9YZ5XEtA1GZYuoDjenLjvg=
golang.org/x/tools v0.23.0/go.mod h1:pnu6ufv6vQkll6szChhK3C3L/ruaIv5eBeztNG8wtsI=
modernc.org/cc/v4 v4.24.4 h1:TFkx1s6dCkQpd6dKurBNmpo+G8Zl4Sq/ztJ+2+DEsh0=
modernc.org/cc/v4 v4.24.4/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.23.16 h1:Z2N+kk38b7SfySC1ZkpGLN2vthNJP1+ZzGZIlH7uBxo=
modernc.org/ccgo/v4 v4.23.16/go.mod h1:nNma8goMTY7aQZQNTyN9AIoJfxav4nvTnvKThAeMDdo=
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
modernc.org/gc/v2 v2.6.3 h1:aJVhcqAte49LF+mGveZ5KPlsp4tdGdAOT4sipJXADjw=
modernc.org/gc/v2 v2.6.3/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI=
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
modernc.org/libc v1.61.13 h1:3LRd6ZO1ezsFiX1y+bHd1ipyEHIJKvuprv0sLTBwLW8=
modernc.org/libc v1.61.13/go.mod h1:8F/uJWL/3nNil0Lgt1Dpz+GgkApWh04N3el3hxJcA6E=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.8.2 h1:cL9L4bcoAObu4NkxOlKWBWtNHIsnnACGF/TbqQ6sbcI=
modernc.org/memory v1.8.2/go.mod h1:ZbjSvMO5NQ1A2i3bWeDiVMxIorXwdClKE/0SZ+BMotU=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.29.10 h1:3u93dz83myFnMilBGCOLbr+HjklS6+5rJLx4q86RDAg=
modernc.org/sqlite v1.29.10/go.mod h1:ItX2a1OVGgNsFh6Dv60JQvGfJfTPHPVpV6DF59akYOA=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+100 -27
View File
@@ -2,10 +2,10 @@ package api
import ( import (
"crypto/rand" "crypto/rand"
"crypto/sha256"
"crypto/subtle" "crypto/subtle"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"fmt"
"net" "net"
"net/http" "net/http"
"strconv" "strconv"
@@ -13,6 +13,8 @@ import (
"time" "time"
"clicd/internal/config" "clicd/internal/config"
"golang.org/x/crypto/argon2"
) )
type ApiKey struct { type ApiKey struct {
@@ -79,14 +81,23 @@ func createApiKey(w http.ResponseWriter, r *http.Request) {
// Generate key: clicd_sk_ + 32 hex chars // Generate key: clicd_sk_ + 32 hex chars
rawBytes := make([]byte, 16) rawBytes := make([]byte, 16)
rand.Read(rawBytes) if _, err := rand.Read(rawBytes); err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to generate API key"})
return
}
rawKey := "clicd_sk_" + hex.EncodeToString(rawBytes) rawKey := "clicd_sk_" + hex.EncodeToString(rawBytes)
keyHash, err := hashAPIKey(rawKey)
if err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to store API key"})
return
}
now := time.Now().Format("2006-01-02 15:04:05") now := time.Now().Format("2006-01-02 15:04:05")
key := config.ApiKeyConfig{ key := config.ApiKeyConfig{
ID: generateShortID(), ID: generateShortID(),
Name: req.Name, Name: req.Name,
KeyHash: hashKey(rawKey), KeyHash: keyHash,
Prefix: rawKey[:13] + "...", Prefix: rawKey[:13] + "...",
IPWhitelist: strings.TrimSpace(req.IPWhitelist), IPWhitelist: strings.TrimSpace(req.IPWhitelist),
CreatedAt: now, CreatedAt: now,
@@ -114,10 +125,59 @@ func generateShortID() string {
return hex.EncodeToString(b) return hex.EncodeToString(b)
} }
// hashKey creates a simple hash for storage (not reversible) const (
func hashKey(key string) string { apiKeyHashPrefix = "argon2id"
sum := sha256.Sum256([]byte(key)) apiKeyHashTime = uint32(3)
return hex.EncodeToString(sum[:]) apiKeyHashMemory = uint32(64 * 1024)
apiKeyHashThreads = uint8(1)
apiKeyHashSaltLength = 16
apiKeyHashKeyLength = uint32(32)
)
// hashAPIKey stores API keys using a salted slow password-hash style function.
func hashAPIKey(key string) (string, error) {
salt := make([]byte, apiKeyHashSaltLength)
if _, err := rand.Read(salt); err != nil {
return "", err
}
return hashAPIKeyWithSalt(key, salt), nil
}
func hashAPIKeyWithSalt(key string, salt []byte) string {
digest := argon2.IDKey([]byte(key), salt, apiKeyHashTime, apiKeyHashMemory, apiKeyHashThreads, apiKeyHashKeyLength)
return fmt.Sprintf("%s$v=19$m=%d,t=%d,p=%d$%s$%s",
apiKeyHashPrefix,
apiKeyHashMemory,
apiKeyHashTime,
apiKeyHashThreads,
hex.EncodeToString(salt),
hex.EncodeToString(digest),
)
}
func verifyAPIKeyHash(rawKey, storedHash string) bool {
parts := strings.Split(storedHash, "$")
if len(parts) != 5 || parts[0] != apiKeyHashPrefix || parts[1] != "v=19" {
return false
}
var memory, iterations uint32
var threads uint8
if _, err := fmt.Sscanf(parts[2], "m=%d,t=%d,p=%d", &memory, &iterations, &threads); err != nil {
return false
}
if memory != apiKeyHashMemory || iterations != apiKeyHashTime || threads != apiKeyHashThreads {
return false
}
salt, err := hex.DecodeString(parts[3])
if err != nil || len(salt) == 0 {
return false
}
expected, err := hex.DecodeString(parts[4])
if err != nil || len(expected) == 0 {
return false
}
digest := argon2.IDKey([]byte(rawKey), salt, iterations, memory, threads, uint32(len(expected)))
return subtle.ConstantTimeCompare(digest, expected) == 1
} }
func legacyHashKey(key string) string { func legacyHashKey(key string) string {
@@ -128,20 +188,36 @@ func legacyHashKey(key string) string {
return hex.EncodeToString(b) return hex.EncodeToString(b)
} }
// validateApiKey checks if the given key is valid and IP is allowed func matchApiKey(rawKey string) (idx int, needsRehash bool) {
func validateApiKey(rawKey, clientIP string) bool {
hashed := hashKey(rawKey)
legacyHashed := legacyHashKey(rawKey) legacyHashed := legacyHashKey(rawKey)
for _, k := range config.AppConfig.ApiKeys { for i, k := range config.AppConfig.ApiKeys {
if subtle.ConstantTimeCompare([]byte(k.KeyHash), []byte(hashed)) == 1 || if verifyAPIKeyHash(rawKey, k.KeyHash) {
subtle.ConstantTimeCompare([]byte(k.KeyHash), []byte(legacyHashed)) == 1 { return i, false
if k.IPWhitelist == "" { }
return true if subtle.ConstantTimeCompare([]byte(k.KeyHash), []byte(legacyHashed)) == 1 {
} return i, true
return isIPAllowed(clientIP, k.IPWhitelist)
} }
} }
return false return -1, false
}
// validateApiKey checks if the given key is valid and IP is allowed.
func validateApiKey(rawKey, clientIP string) bool {
idx, needsRehash := matchApiKey(rawKey)
if idx < 0 {
return false
}
k := config.AppConfig.ApiKeys[idx]
if k.IPWhitelist != "" && !isIPAllowed(clientIP, k.IPWhitelist) {
return false
}
if needsRehash {
if newHash, err := hashAPIKey(rawKey); err == nil {
config.AppConfig.ApiKeys[idx].KeyHash = newHash
config.SaveConfig()
}
}
return true
} }
func apiKeyFromRequest(r *http.Request) string { func apiKeyFromRequest(r *http.Request) string {
@@ -228,17 +304,14 @@ func ip4ToUint32(ip net.IP) uint32 {
return uint32(ip[0])<<24 | uint32(ip[1])<<16 | uint32(ip[2])<<8 | uint32(ip[3]) return uint32(ip[0])<<24 | uint32(ip[1])<<16 | uint32(ip[2])<<8 | uint32(ip[3])
} }
// updateApiKeyLastUsed marks the key as recently used // updateApiKeyLastUsed marks the key as recently used.
func updateApiKeyLastUsed(rawKey string) { func updateApiKeyLastUsed(rawKey string) {
hashed := hashKey(rawKey) idx, _ := matchApiKey(rawKey)
now := time.Now().Format("2006-01-02 15:04:05") if idx < 0 {
for i := range config.AppConfig.ApiKeys { return
if config.AppConfig.ApiKeys[i].KeyHash == hashed {
config.AppConfig.ApiKeys[i].LastUsed = now
config.SaveConfig()
return
}
} }
config.AppConfig.ApiKeys[idx].LastUsed = time.Now().Format("2006-01-02 15:04:05")
config.SaveConfig()
} }
// ApiKeyMiddleware authenticates requests via X-API-Key header or Authorization bearer. // ApiKeyMiddleware authenticates requests via X-API-Key header or Authorization bearer.
+102
View File
@@ -0,0 +1,102 @@
package api
import (
"strings"
"testing"
"clicd/internal/config"
)
func TestHashAPIKeyUsesSaltedArgon2idHash(t *testing.T) {
raw := "clicd_sk_0123456789abcdef0123456789abcdef"
h1, err := hashAPIKey(raw)
if err != nil {
t.Fatal(err)
}
h2, err := hashAPIKey(raw)
if err != nil {
t.Fatal(err)
}
if h1 == h2 {
t.Fatal("expected salted hashes to differ")
}
if !strings.HasPrefix(h1, apiKeyHashPrefix+"$") || !strings.HasPrefix(h2, apiKeyHashPrefix+"$") {
t.Fatalf("expected argon2id hashes, got %q and %q", h1, h2)
}
if !verifyAPIKeyHash(raw, h1) || !verifyAPIKeyHash(raw, h2) {
t.Fatal("argon2id hashes did not verify")
}
if verifyAPIKeyHash(raw+"x", h1) {
t.Fatal("argon2id hash verified wrong key")
}
}
func TestValidateApiKeyAllowsArgon2idAndUpdatesLastUsed(t *testing.T) {
raw := "clicd_sk_0123456789abcdef0123456789abcdef"
hash, err := hashAPIKey(raw)
if err != nil {
t.Fatal(err)
}
config.AppConfig = &config.ClicdConfig{
ApiKeys: []config.ApiKeyConfig{{
ID: "key1",
Name: "test",
KeyHash: hash,
}},
}
if !validateApiKey(raw, "127.0.0.1") {
t.Fatal("validateApiKey rejected valid argon2id key")
}
updateApiKeyLastUsed(raw)
if config.AppConfig.ApiKeys[0].LastUsed == "" {
t.Fatal("LastUsed was not updated")
}
}
func TestValidateApiKeyMigratesLegacyHash(t *testing.T) {
raw := "clicd_sk_0123456789abcdef0123456789abcdef"
config.AppConfig = &config.ClicdConfig{
ApiKeys: []config.ApiKeyConfig{{
ID: "legacy",
Name: "legacy",
KeyHash: legacyHashKey(raw),
}},
}
if !validateApiKey(raw, "127.0.0.1") {
t.Fatal("validateApiKey rejected valid legacy key")
}
migrated := config.AppConfig.ApiKeys[0].KeyHash
if migrated == legacyHashKey(raw) {
t.Fatal("legacy key hash was not migrated")
}
if !verifyAPIKeyHash(raw, migrated) {
t.Fatal("migrated key hash does not verify")
}
}
func TestValidateApiKeyAppliesIPWhitelist(t *testing.T) {
raw := "clicd_sk_0123456789abcdef0123456789abcdef"
hash, err := hashAPIKey(raw)
if err != nil {
t.Fatal(err)
}
config.AppConfig = &config.ClicdConfig{
ApiKeys: []config.ApiKeyConfig{{
ID: "key1",
Name: "test",
KeyHash: hash,
IPWhitelist: "192.0.2.10",
}},
}
if validateApiKey(raw, "198.51.100.10") {
t.Fatal("validateApiKey allowed disallowed IP")
}
if !validateApiKey(raw, "192.0.2.10") {
t.Fatal("validateApiKey rejected allowed IP")
}
}
+5
View File
@@ -75,8 +75,10 @@ func claimsFromToken(tokenString string) (jwt.MapClaims, bool) {
if subUser, _ := claims["sub_user"].(string); subUser != "" { if subUser, _ := claims["sub_user"].(string); subUser != "" {
tokenVersionFloat, hasVersion := claims["token_version"].(float64) tokenVersionFloat, hasVersion := claims["token_version"].(float64)
tokenVersion := int(tokenVersionFloat) tokenVersion := int(tokenVersionFloat)
foundSubUser := false
for i := range config.AppConfig.SubUsers { for i := range config.AppConfig.SubUsers {
if config.AppConfig.SubUsers[i].Username == subUser { if config.AppConfig.SubUsers[i].Username == subUser {
foundSubUser = true
stored := config.AppConfig.SubUsers[i].TokenVersion stored := config.AppConfig.SubUsers[i].TokenVersion
// If stored version > 0, require token_version to match exactly. // If stored version > 0, require token_version to match exactly.
// This also rejects legacy tokens that lack token_version entirely. // This also rejects legacy tokens that lack token_version entirely.
@@ -86,6 +88,9 @@ func claimsFromToken(tokenString string) (jwt.MapClaims, bool) {
break break
} }
} }
if !foundSubUser {
return nil, false
}
} }
return claims, ok return claims, ok
+62 -17
View File
@@ -2,10 +2,12 @@ package api
import ( import (
"encoding/json" "encoding/json"
"fmt"
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
"time" "time"
"unicode"
"clicd/internal/config" "clicd/internal/config"
"clicd/internal/lxc" "clicd/internal/lxc"
@@ -105,10 +107,7 @@ func HandleSingleContainer(w http.ResponseWriter, r *http.Request) {
} }
func listContainers(w http.ResponseWriter, r *http.Request) { func listContainers(w http.ResponseWriter, r *http.Request) {
containers, err := lxcManager.ListContainers() containers, _ := listByRuntime()
if err != nil {
containers = config.AppConfig.Containers
}
containers = filterContainersForRequest(r, containers) containers = filterContainersForRequest(r, containers)
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: containers}) jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: containers})
} }
@@ -123,11 +122,12 @@ func createContainer(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Container name is required"}) jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Container name is required"})
return return
} }
cfg.Virtualization = runtimeFromRequest(cfg.Virtualization)
if cfg.TemplateID == "" { if cfg.TemplateID == "" {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Template is required"}) jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Template is required"})
return return
} }
if !isTemplateEnabledAndDownloaded(cfg.TemplateID) { if !isImageEnabledAndDownloaded(cfg.TemplateID, cfg.Virtualization) {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Template is not enabled or downloaded"}) jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Template is not enabled or downloaded"})
return return
} }
@@ -150,7 +150,7 @@ func createContainer(w http.ResponseWriter, r *http.Request) {
if cfg.SnapshotLimit <= 0 { if cfg.SnapshotLimit <= 0 {
cfg.SnapshotLimit = config.DefaultSnapshotLimit cfg.SnapshotLimit = config.DefaultSnapshotLimit
} }
if err := validateContainerResourceRequest(cfg.VCPU, cfg.RAMMB, cfg.DiskGB); err != nil { if err := validateRuntimeResourceRequest(cfg.Virtualization, cfg.VCPU, cfg.RAMMB, cfg.DiskGB); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()}) jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
return return
} }
@@ -166,7 +166,7 @@ func createContainer(w http.ResponseWriter, r *http.Request) {
} }
} }
if err := lxcManager.CreateContainer(cfg); err != nil { if err := createByRuntime(cfg); err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()}) jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
return return
} }
@@ -179,11 +179,15 @@ func getContainer(w http.ResponseWriter, r *http.Request, id int) {
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"}) jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"})
return return
} }
if c.IsKVM() && c.Status == "running" {
_, _ = kvmManager.RefreshVNCPort(c.ID)
_, _ = kvmManager.RefreshNetwork(c.ID)
}
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: c}) jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: c})
} }
func getUsage(w http.ResponseWriter, r *http.Request, id int) { func getUsage(w http.ResponseWriter, r *http.Request, id int) {
usage, err := lxcManager.GetResourceUsage(id) usage, err := usageByRuntime(id)
if err != nil { if err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()}) jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
return return
@@ -192,7 +196,7 @@ func getUsage(w http.ResponseWriter, r *http.Request, id int) {
} }
func getTraffic(w http.ResponseWriter, r *http.Request, id int) { func getTraffic(w http.ResponseWriter, r *http.Request, id int) {
info := lxcManager.GetTrafficInfo(id) info := trafficByRuntime(id)
if info == nil { if info == nil {
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"}) jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"})
return return
@@ -281,7 +285,7 @@ func updateResourceLimit(w http.ResponseWriter, r *http.Request, id int) {
if req.RAMMB > 0 { if req.RAMMB > 0 {
nextRAMMB = req.RAMMB nextRAMMB = req.RAMMB
} }
if err := validateContainerResourceRequest(nextVCPU, nextRAMMB, c.DiskGB); err != nil { if err := validateRuntimeResourceRequest(c.Runtime(), nextVCPU, nextRAMMB, c.DiskGB); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()}) jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
return return
} }
@@ -294,13 +298,17 @@ func updateResourceLimit(w http.ResponseWriter, r *http.Request, id int) {
// Re-apply resource limits to running container // Re-apply resource limits to running container
if c.Status == "running" { if c.Status == "running" {
if err := lxcManager.ApplyContainerLimits(c); err != nil { if err := applyLimitsByRuntime(c); err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()}) jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
return return
} }
} }
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Resource limits updated"}) msg := "Resource limits updated"
if c.IsKVM() && c.Status == "running" {
msg = "资源已保存,请关机重启虚拟机后生效"
}
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: msg})
} }
func getRandomPort(w http.ResponseWriter, r *http.Request, id int) { func getRandomPort(w http.ResponseWriter, r *http.Request, id int) {
@@ -354,10 +362,7 @@ func HandleDashboard(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return return
} }
containers, err := lxcManager.ListContainers() containers, _ := listByRuntime()
if err != nil {
containers = config.AppConfig.Containers
}
running := 0 running := 0
stopped := 0 stopped := 0
for _, c := range containers { for _, c := range containers {
@@ -391,7 +396,24 @@ func resetSSHPassword(w http.ResponseWriter, r *http.Request, id int) {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "容器已到期,不允许此操作"}) jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "容器已到期,不允许此操作"})
return return
} }
newPassword, err := lxcManager.ResetSSHPassword(id) var req struct {
Password string `json:"password"`
}
if r.Body != nil {
decoder := json.NewDecoder(r.Body)
if err := decoder.Decode(&req); err != nil && err.Error() != "EOF" {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
return
}
}
password := strings.TrimSpace(req.Password)
if password != "" {
if err := validateSSHPassword(password); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
return
}
}
newPassword, err := resetPasswordByRuntime(id, password)
if err != nil { if err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()}) jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
return return
@@ -403,6 +425,29 @@ 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
}
func addPortMapping(w http.ResponseWriter, r *http.Request, id int) { func addPortMapping(w http.ResponseWriter, r *http.Request, id int) {
var pm config.PortMapping var pm config.PortMapping
if err := json.NewDecoder(r.Body).Decode(&pm); err != nil { if err := json.NewDecoder(r.Body).Decode(&pm); err != nil {
+4 -3
View File
@@ -8,10 +8,11 @@ import (
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
"syscall"
"time" "time"
"clicd/internal/lxc" "clicd/internal/lxc"
"golang.org/x/sys/unix"
) )
type HostInfo struct { type HostInfo struct {
@@ -135,8 +136,8 @@ func getMemoryInfo() MemoryInfo {
} }
func getDiskInfo() DiskInfo { func getDiskInfo() DiskInfo {
var stat syscall.Statfs_t var stat unix.Statfs_t
if err := syscall.Statfs("/", &stat); err != nil { if err := unix.Statfs("/", &stat); err != nil {
// Try command-based fallback // Try command-based fallback
cmd := exec.Command("df", "-BG", "/") cmd := exec.Command("df", "-BG", "/")
output, err := cmd.Output() output, err := cmd.Output()
+353 -65
View File
@@ -1,6 +1,7 @@
package api package api
import ( import (
"context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
@@ -8,27 +9,152 @@ import (
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"sync" "sync"
"time"
"clicd/internal/config" "clicd/internal/config"
"clicd/internal/kvm"
"clicd/internal/lxc" "clicd/internal/lxc"
) )
// ImageInfo represents a template image with its download/enable status. // ImageInfo represents a template image with its download/enable status.
type ImageInfo struct { type ImageInfo struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Distro string `json:"distro"` Type string `json:"type"`
Release string `json:"release"` Distro string `json:"distro"`
Arch string `json:"arch"` Release string `json:"release"`
Description string `json:"description"` Arch string `json:"arch"`
Downloaded bool `json:"downloaded"` Description string `json:"description"`
Enabled bool `json:"enabled"` Downloaded bool `json:"downloaded"`
Downloading bool `json:"downloading"` Enabled bool `json:"enabled"`
SizeBytes int64 `json:"size_bytes"` Downloading bool `json:"downloading"`
Progress int `json:"progress"`
DownloadedBytes int64 `json:"downloaded_bytes"`
TotalBytes int64 `json:"total_bytes"`
Stage string `json:"stage,omitempty"`
Error string `json:"error,omitempty"`
SizeBytes int64 `json:"size_bytes"`
ManualPath string `json:"manual_path,omitempty"`
Desktop string `json:"desktop,omitempty"`
} }
var imageDownloadsMu sync.Mutex var imageDownloadsMu sync.Mutex
var imageDownloads = map[string]bool{} var imageDownloads = map[string]*imageDownloadStatus{}
type imageDownloadStatus struct {
Downloading bool
Progress int
DownloadedBytes int64
TotalBytes int64
Stage string
Error string
Cancel context.CancelFunc
UpdatedAt time.Time
}
type imageDownloadSnapshot struct {
Downloading bool
Progress int
DownloadedBytes int64
TotalBytes int64
Stage string
Error string
}
func imageDownloadInfo(id string) imageDownloadSnapshot {
imageDownloadsMu.Lock()
defer imageDownloadsMu.Unlock()
st := imageDownloads[id]
if st == nil {
return imageDownloadSnapshot{}
}
return imageDownloadSnapshot{
Downloading: st.Downloading,
Progress: st.Progress,
DownloadedBytes: st.DownloadedBytes,
TotalBytes: st.TotalBytes,
Stage: st.Stage,
Error: st.Error,
}
}
func startImageDownload(id, stage string) (context.Context, bool) {
imageDownloadsMu.Lock()
defer imageDownloadsMu.Unlock()
if st := imageDownloads[id]; st != nil && st.Downloading {
return nil, false
}
ctx, cancel := context.WithCancel(context.Background())
imageDownloads[id] = &imageDownloadStatus{
Downloading: true,
Stage: stage,
Cancel: cancel,
UpdatedAt: time.Now(),
}
return ctx, true
}
func updateImageDownload(id string, update func(*imageDownloadStatus)) {
imageDownloadsMu.Lock()
defer imageDownloadsMu.Unlock()
st := imageDownloads[id]
if st == nil {
return
}
update(st)
st.UpdatedAt = time.Now()
}
func finishImageDownload(id string, err error) {
imageDownloadsMu.Lock()
defer imageDownloadsMu.Unlock()
st := imageDownloads[id]
if st == nil {
return
}
st.Downloading = false
st.Cancel = nil
st.UpdatedAt = time.Now()
if err != nil {
st.Error = err.Error()
return
}
delete(imageDownloads, id)
}
func clearImageDownload(id string) {
imageDownloadsMu.Lock()
delete(imageDownloads, id)
imageDownloadsMu.Unlock()
}
func isImageDownloadActive(id string) bool {
imageDownloadsMu.Lock()
defer imageDownloadsMu.Unlock()
st := imageDownloads[id]
return st != nil && st.Downloading
}
func lxcImageDownloadTempName(id string) string {
return fmt.Sprintf("clicd-img-dl-%s", id)
}
func cleanupLXCImageDownloadTemp(id string) {
tmpName := lxcImageDownloadTempName(id)
exec.Command("lxc-destroy", "-n", tmpName, "-f").Run()
os.RemoveAll(filepath.Join("/var/lib/lxc", tmpName))
}
func cleanupOldImageDownloadErrors() {
imageDownloadsMu.Lock()
defer imageDownloadsMu.Unlock()
cutoff := time.Now().Add(-10 * time.Minute)
for id, st := range imageDownloads {
if !st.Downloading && st.UpdatedAt.Before(cutoff) {
delete(imageDownloads, id)
}
}
}
// isImageDownloaded checks if the LXC download cache exists for a template. // isImageDownloaded checks if the LXC download cache exists for a template.
func isImageDownloaded(distro, release, arch string) bool { func isImageDownloaded(distro, release, arch string) bool {
@@ -78,6 +204,9 @@ func getEnabledImageSet() map[string]bool {
for _, t := range lxc.GetTemplates() { for _, t := range lxc.GetTemplates() {
set[t.ID] = true set[t.ID] = true
} }
for _, t := range kvm.GetImages() {
set[t.ID] = true
}
} else { } else {
for _, id := range config.AppConfig.EnabledImages { for _, id := range config.AppConfig.EnabledImages {
set[id] = true set[id] = true
@@ -93,31 +222,66 @@ func HandleImages(w http.ResponseWriter, r *http.Request) {
return return
} }
templates := lxc.GetTemplates()
enabledSet := getEnabledImageSet() enabledSet := getEnabledImageSet()
cleanupOldImageDownloadErrors()
images := make([]ImageInfo, 0, len(templates)) templates := lxc.GetTemplates()
images := make([]ImageInfo, 0, len(templates)+len(kvm.GetImages()))
for _, t := range templates { for _, t := range templates {
_, downloading := imageDownloads[t.ID] dl := imageDownloadInfo(t.ID)
downloaded, size := imageDownloadedInfo(t.Distro, t.Release, t.Arch) downloaded, size := imageDownloadedInfo(t.Distro, t.Release, t.Arch)
images = append(images, ImageInfo{ images = append(images, ImageInfo{
ID: t.ID, ID: t.ID,
Name: t.Name, Name: t.Name,
Distro: t.Distro, Type: config.VirtualizationLXC,
Release: t.Release, Distro: t.Distro,
Arch: t.Arch, Release: t.Release,
Description: t.Description, Arch: t.Arch,
Downloaded: downloaded, Description: t.Description,
Enabled: enabledSet[t.ID], Downloaded: downloaded,
Downloading: downloading, Enabled: enabledSet[t.ID],
SizeBytes: size, Downloading: dl.Downloading,
Progress: dl.Progress,
DownloadedBytes: dl.DownloadedBytes,
TotalBytes: dl.TotalBytes,
Stage: dl.Stage,
Error: dl.Error,
SizeBytes: size,
})
}
for _, t := range kvm.GetImages() {
dl := imageDownloadInfo(t.ID)
downloaded, size := kvm.ImageDownloadedInfo(t.ID)
manualPath := ""
if t.Distro == "windows" {
manualPath = kvm.ImagePath(t.ID)
}
images = append(images, ImageInfo{
ID: t.ID,
Name: t.Name,
Type: config.VirtualizationKVM,
Distro: t.Distro,
Release: t.Release,
Arch: t.Arch,
Description: t.Description,
Downloaded: downloaded,
Enabled: enabledSet[t.ID],
Downloading: dl.Downloading,
Progress: dl.Progress,
DownloadedBytes: dl.DownloadedBytes,
TotalBytes: dl.TotalBytes,
Stage: dl.Stage,
Error: dl.Error,
SizeBytes: size,
ManualPath: manualPath,
Desktop: t.Desktop,
}) })
} }
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: images}) jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: images})
} }
// HandleImageDownload downloads a template image from the LXC image server. // HandleImageDownload starts a template image download in the background.
func HandleImageDownload(w http.ResponseWriter, r *http.Request) { func HandleImageDownload(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
@@ -134,59 +298,134 @@ func HandleImageDownload(w http.ResponseWriter, r *http.Request) {
tmpl := lxc.FindTemplate(req.TemplateID) tmpl := lxc.FindTemplate(req.TemplateID)
if tmpl == nil { if tmpl == nil {
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Template not found"}) image := kvm.FindImage(req.TemplateID)
if image == nil {
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Template not found"})
return
}
if ok, _ := kvm.ImageDownloadedInfo(image.ID); ok {
ensureImageEnabled(image.ID)
clearImageDownload(image.ID)
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Already downloaded"})
return
}
ctx, ok := startImageDownload(image.ID, "downloading")
if !ok {
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: "Already downloading"})
return
}
go func(image kvm.Image) {
err := kvm.DownloadImageWithProgress(ctx, image, func(p kvm.DownloadProgress) {
updateImageDownload(image.ID, func(st *imageDownloadStatus) {
if p.Stage != "" {
st.Stage = p.Stage
}
if p.DownloadedBytes > 0 || p.TotalBytes > 0 {
st.DownloadedBytes = p.DownloadedBytes
st.TotalBytes = p.TotalBytes
}
st.Progress = p.Percent
})
})
if err != nil {
if ctx.Err() != nil {
os.Remove(kvm.ImagePath(image.ID) + ".tmp")
os.Remove(kvm.ImagePath(image.ID))
finishImageDownload(image.ID, nil)
return
}
finishImageDownload(image.ID, err)
return
}
ensureImageEnabled(image.ID)
finishImageDownload(image.ID, nil)
}(*image)
jsonResponse(w, http.StatusAccepted, APIResponse{Success: true, Message: "Download started"})
return return
} }
// Already downloaded? Just enable if needed. // Already downloaded? Just enable if needed.
if isImageDownloaded(tmpl.Distro, tmpl.Release, tmpl.Arch) { if isImageDownloaded(tmpl.Distro, tmpl.Release, tmpl.Arch) {
ensureImageEnabled(tmpl.ID) ensureImageEnabled(tmpl.ID)
clearImageDownload(tmpl.ID)
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Already downloaded"}) jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Already downloaded"})
return return
} }
// Already downloading? ctx, ok := startImageDownload(tmpl.ID, "lxc-create")
imageDownloadsMu.Lock() if !ok {
if imageDownloads[req.TemplateID] {
imageDownloadsMu.Unlock()
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: "Already downloading"}) jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: "Already downloading"})
return return
} }
imageDownloads[req.TemplateID] = true
imageDownloadsMu.Unlock()
defer func() { go func(tmpl lxc.Template) {
imageDownloadsMu.Lock() // Download via lxc-create with a temp container, then destroy it.
delete(imageDownloads, req.TemplateID) tmpName := lxcImageDownloadTempName(tmpl.ID)
imageDownloadsMu.Unlock() args := []string{"-n", tmpName, "-t", "download", "--",
}() "-d", tmpl.Distro, "-r", tmpl.Release, "-a", tmpl.Arch}
if tmpl.Variant != "" {
// Auto-enable on download args = append(args, "--variant", tmpl.Variant)
ensureImageEnabled(tmpl.ID) }
updateImageDownload(tmpl.ID, func(st *imageDownloadStatus) {
// Download via lxc-create with a temp container, then destroy it. st.Stage = "lxc-create"
tmpName := fmt.Sprintf("clicd-img-dl-%s", tmpl.ID)
args := []string{"-n", tmpName, "-t", "download", "--",
"-d", tmpl.Distro, "-r", tmpl.Release, "-a", tmpl.Arch}
if tmpl.Variant != "" {
args = append(args, "--variant", tmpl.Variant)
}
cmd := exec.Command("lxc-create", args...)
output, err := cmd.CombinedOutput()
// Clean up the temp container unconditionally.
exec.Command("lxc-destroy", "-n", tmpName, "-f").Run()
os.RemoveAll(filepath.Join("/var/lib/lxc", tmpName))
if err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{
Success: false,
Message: fmt.Sprintf("Download failed: %v, output: %s", err, string(output)),
}) })
cmd := exec.CommandContext(ctx, "lxc-create", args...)
output, err := cmd.CombinedOutput()
// Clean up the temp container unconditionally.
cleanupLXCImageDownloadTemp(tmpl.ID)
if err != nil {
if ctx.Err() != nil {
finishImageDownload(tmpl.ID, nil)
return
}
err = fmt.Errorf("Download failed: %v, output: %s", err, string(output))
finishImageDownload(tmpl.ID, err)
return
}
ensureImageEnabled(tmpl.ID)
finishImageDownload(tmpl.ID, nil)
}(*tmpl)
jsonResponse(w, http.StatusAccepted, APIResponse{Success: true, Message: "Download started"})
}
// HandleImageCancel cancels an in-progress image download.
func HandleImageCancel(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
var req struct {
TemplateID string `json:"template_id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.TemplateID == "" {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "template_id required"})
return return
} }
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Downloaded successfully"}) imageDownloadsMu.Lock()
st := imageDownloads[req.TemplateID]
if st == nil || !st.Downloading || st.Cancel == nil {
imageDownloadsMu.Unlock()
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "No active download"})
return
}
cancel := st.Cancel
st.Stage = "canceling"
st.UpdatedAt = time.Now()
imageDownloadsMu.Unlock()
cancel()
if image := kvm.FindImage(req.TemplateID); image != nil {
os.Remove(kvm.ImagePath(image.ID) + ".tmp")
os.Remove(kvm.ImagePath(image.ID))
}
if tmpl := lxc.FindTemplate(req.TemplateID); tmpl != nil {
go cleanupLXCImageDownloadTemp(tmpl.ID)
}
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Cancel requested"})
} }
// HandleImageDelete deletes a cached template image from disk. // HandleImageDelete deletes a cached template image from disk.
@@ -203,9 +442,22 @@ func HandleImageDelete(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "template_id required"}) jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "template_id required"})
return return
} }
if isImageDownloadActive(req.TemplateID) {
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: "Image is downloading; cancel it before deleting"})
return
}
tmpl := lxc.FindTemplate(req.TemplateID) tmpl := lxc.FindTemplate(req.TemplateID)
if tmpl == nil { if tmpl == nil {
if image := kvm.FindImage(req.TemplateID); image != nil {
if err := kvm.DeleteImage(image.ID); err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to delete image cache: " + err.Error()})
return
}
removeImageEnabled(image.ID)
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Deleted"})
return
}
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Template not found"}) jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Template not found"})
return return
} }
@@ -259,13 +511,27 @@ func HandleEnabledImages(w http.ResponseWriter, r *http.Request) {
return return
} }
templates := lxc.GetTemplates() runtime := runtimeFromRequest(r.URL.Query().Get("type"))
enabledSet := getEnabledImageSet() enabledSet := getEnabledImageSet()
result := make([]lxc.Template, 0) result := make([]map[string]string, 0)
for _, t := range templates { if runtime == config.VirtualizationKVM {
if enabledSet[t.ID] && isImageDownloaded(t.Distro, t.Release, t.Arch) { for _, t := range kvm.GetImages() {
result = append(result, t) if downloaded, _ := kvm.ImageDownloadedInfo(t.ID); enabledSet[t.ID] && downloaded {
result = append(result, map[string]string{
"id": t.ID, "name": t.Name, "distro": t.Distro, "release": t.Release, "arch": t.Arch,
"description": t.Description, "type": config.VirtualizationKVM, "desktop": t.Desktop,
})
}
}
} else {
for _, t := range lxc.GetTemplates() {
if enabledSet[t.ID] && isImageDownloaded(t.Distro, t.Release, t.Arch) {
result = append(result, map[string]string{
"id": t.ID, "name": t.Name, "distro": t.Distro, "release": t.Release, "arch": t.Arch,
"variant": t.Variant, "description": t.Description, "type": config.VirtualizationLXC,
})
}
} }
} }
@@ -273,6 +539,20 @@ func HandleEnabledImages(w http.ResponseWriter, r *http.Request) {
} }
func isTemplateEnabledAndDownloaded(templateID string) bool { func isTemplateEnabledAndDownloaded(templateID string) bool {
return isImageEnabledAndDownloaded(templateID, runtimeFromTemplateID(templateID))
}
func isImageEnabledAndDownloaded(templateID string, runtime string) bool {
runtime = runtimeFromRequest(runtime)
if runtime == config.VirtualizationKVM {
image := kvm.FindImage(templateID)
if image == nil {
return false
}
enabledSet := getEnabledImageSet()
downloaded, _ := kvm.ImageDownloadedInfo(image.ID)
return enabledSet[image.ID] && downloaded
}
tmpl := lxc.FindTemplate(templateID) tmpl := lxc.FindTemplate(templateID)
if tmpl == nil { if tmpl == nil {
return false return false
@@ -288,6 +568,9 @@ func ensureImageEnabled(id string) {
for _, t := range lxc.GetTemplates() { for _, t := range lxc.GetTemplates() {
config.AppConfig.EnabledImages = append(config.AppConfig.EnabledImages, t.ID) config.AppConfig.EnabledImages = append(config.AppConfig.EnabledImages, t.ID)
} }
for _, t := range kvm.GetImages() {
config.AppConfig.EnabledImages = append(config.AppConfig.EnabledImages, t.ID)
}
config.SaveConfig() config.SaveConfig()
return // Already contains all IDs including this one return // Already contains all IDs including this one
} }
@@ -313,6 +596,11 @@ func removeImageEnabled(id string) {
config.AppConfig.EnabledImages = append(config.AppConfig.EnabledImages, t.ID) config.AppConfig.EnabledImages = append(config.AppConfig.EnabledImages, t.ID)
} }
} }
for _, t := range kvm.GetImages() {
if t.ID != id {
config.AppConfig.EnabledImages = append(config.AppConfig.EnabledImages, t.ID)
}
}
config.SaveConfig() config.SaveConfig()
return return
} }
+1 -1
View File
@@ -12,7 +12,7 @@ func HandleIPv6Status(w http.ResponseWriter, r *http.Request) {
} }
func assignIPv6(w http.ResponseWriter, r *http.Request, id int) { func assignIPv6(w http.ResponseWriter, r *http.Request, id int) {
c, err := lxcManager.AssignIPv6(id) c, err := assignIPv6ByRuntime(id)
if err != nil { if err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()}) jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
return return
-227
View File
@@ -1,227 +0,0 @@
package api
import (
"encoding/json"
"fmt"
"net/http"
"os"
"os/exec"
"strconv"
"strings"
"clicd/internal/config"
)
// HandleOversell handles GET/POST for oversell config
func HandleOversell(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
getOversell(w, r)
case http.MethodPost:
updateOversell(w, r)
default:
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
}
}
func getOversell(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: config.AppConfig.Oversell})
}
func updateOversell(w http.ResponseWriter, r *http.Request) {
var cfg config.OversellConfig
if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
return
}
if cfg.SubUserSnapshotLimit <= 0 {
cfg.SubUserSnapshotLimit = 3
}
// Apply KSM
if cfg.KSMEnabled {
exec.Command("sh", "-c", "echo 1 > /sys/kernel/mm/ksm/run 2>/dev/null").Run()
exec.Command("sh", "-c", "echo 1000 > /sys/kernel/mm/ksm/sleep_millisecs 2>/dev/null").Run()
} else {
exec.Command("sh", "-c", "echo 0 > /sys/kernel/mm/ksm/run 2>/dev/null").Run()
}
// Apply swappiness
if cfg.Swappiness >= 0 && cfg.Swappiness <= 100 {
exec.Command("sh", "-c", fmt.Sprintf("echo %d > /proc/sys/vm/swappiness", cfg.Swappiness)).Run()
}
// Oversell multipliers are capacity-planning values. They must not increase
// an individual container's CPU or RAM limits.
reapplyContainerLimits()
config.AppConfig.Oversell = cfg
if err := config.SaveConfig(); err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to save config"})
return
}
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Oversell config updated", Data: cfg})
}
// reapplyContainerLimits restores cgroup limits for all running containers from
// their assigned container resources.
func reapplyContainerLimits() {
for _, c := range config.AppConfig.Containers {
if c.Status != "running" {
continue
}
if err := lxcManager.ApplyContainerLimits(&c); err != nil {
fmt.Printf("Warning: failed to reapply resource limits for %s: %v\n", c.LxcName(), err)
}
}
}
// HandleOversellStatus returns current oversell resource usage
func HandleOversellStatus(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
status := map[string]interface{}{
"ksm_active": isKSMEnabled(),
"ksm_pages": getKSMPages(),
"ksm_supported": isKSMSupported(),
"swappiness": getSwappiness(),
"reclaim_supported": isMemoryReclaimSupported(),
"allocated_cpu": getAllocatedCPU(),
"allocated_ram_mb": getAllocatedRAM(),
"allocated_disk_gb": getAllocatedDisk(),
}
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: status})
}
// HandleOversellReclaim triggers one cgroup v2 memory.reclaim pass for running containers.
func HandleOversellReclaim(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
result := reclaimContainerMemory()
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Memory reclaim triggered", Data: result})
}
func reclaimContainerMemory() map[string]interface{} {
attempted := 0
reclaimed := 0
unsupported := 0
errors := make([]string, 0)
for _, c := range config.AppConfig.Containers {
if c.Status != "running" {
continue
}
attempted++
reclaimPath := findMemoryReclaimPath(c.LxcName())
if reclaimPath == "" {
unsupported++
continue
}
if err := os.WriteFile(reclaimPath, []byte("64M"), 0644); err != nil {
errors = append(errors, fmt.Sprintf("%s: %v", c.Name, err))
continue
}
reclaimed++
}
return map[string]interface{}{
"attempted": attempted,
"reclaimed": reclaimed,
"unsupported": unsupported,
"errors": errors,
}
}
func isKSMEnabled() bool {
data, err := os.ReadFile("/sys/kernel/mm/ksm/run")
if err != nil {
return false
}
return strings.TrimSpace(string(data)) == "1"
}
func isKSMSupported() bool {
if _, err := os.Stat("/sys/kernel/mm/ksm/run"); err != nil {
return false
}
return true
}
func getKSMPages() int64 {
data, err := os.ReadFile("/sys/kernel/mm/ksm/pages_shared")
if err != nil {
return 0
}
val, _ := strconv.ParseInt(strings.TrimSpace(string(data)), 10, 64)
return val
}
func getSwappiness() int {
data, err := os.ReadFile("/proc/sys/vm/swappiness")
if err != nil {
return 60
}
val, _ := strconv.Atoi(strings.TrimSpace(string(data)))
return val
}
func isMemoryReclaimSupported() bool {
if _, err := os.Stat("/sys/fs/cgroup/memory.reclaim"); err == nil {
return true
}
for _, c := range config.AppConfig.Containers {
if c.Status != "running" {
continue
}
if findMemoryReclaimPath(c.LxcName()) != "" {
return true
}
}
return false
}
func findMemoryReclaimPath(lxcName string) string {
candidates := []string{
fmt.Sprintf("/sys/fs/cgroup/lxc/%s/memory.reclaim", lxcName),
fmt.Sprintf("/sys/fs/cgroup/lxc.payload.%s/memory.reclaim", lxcName),
fmt.Sprintf("/sys/fs/cgroup/system.slice/lxc@%s.service/memory.reclaim", lxcName),
}
for _, path := range candidates {
if _, err := os.Stat(path); err == nil {
return path
}
}
return ""
}
func getAllocatedCPU() float64 {
total := 0.0
for _, c := range config.AppConfig.Containers {
total += c.VCPU
}
return total
}
func getAllocatedRAM() int64 {
total := int64(0)
for _, c := range config.AppConfig.Containers {
total += int64(c.RAMMB)
}
return total
}
func getAllocatedDisk() int64 {
total := int64(0)
for _, c := range config.AppConfig.Containers {
total += int64(c.DiskGB)
}
return total
}
+172
View File
@@ -0,0 +1,172 @@
package api
import (
"fmt"
"math"
"os"
"strings"
"clicd/internal/config"
"clicd/internal/kvm"
"clicd/internal/lxc"
)
var kvmManager = kvm.NewManager()
func runtimeFromRequest(value string) string {
return config.NormalizeVirtualization(value)
}
func runtimeFromTemplateID(templateID string) string {
if kvm.FindImage(templateID) != nil {
return config.VirtualizationKVM
}
return config.VirtualizationLXC
}
func createByRuntime(cfg lxc.ContainerConfig) error {
cfg.Virtualization = runtimeFromRequest(cfg.Virtualization)
if cfg.Virtualization == config.VirtualizationKVM {
return kvmManager.CreateContainer(cfg)
}
return lxcManager.CreateContainer(cfg)
}
func startByRuntime(id int) error {
c := config.FindContainer(id)
if c != nil && c.IsKVM() {
return kvmManager.StartContainer(id)
}
return lxcManager.StartContainer(id)
}
func stopByRuntime(id int) error {
c := config.FindContainer(id)
if c != nil && c.IsKVM() {
return kvmManager.StopContainer(id)
}
return lxcManager.StopContainer(id)
}
func restartByRuntime(id int) error {
c := config.FindContainer(id)
if c != nil && c.IsKVM() {
return kvmManager.RestartContainer(id)
}
return lxcManager.RestartContainer(id)
}
func destroyByRuntime(id int) error {
c := config.FindContainer(id)
if c != nil && c.IsKVM() {
return kvmManager.DestroyContainer(id)
}
return lxcManager.DestroyContainer(id)
}
func reinstallByRuntime(id int, templateID string) error {
c := config.FindContainer(id)
if c != nil && c.IsKVM() {
return kvmManager.ReinstallContainer(id, templateID)
}
return lxcManager.ReinstallContainer(id, templateID)
}
func resetPasswordByRuntime(id int, password string) (string, error) {
c := config.FindContainer(id)
if c != nil && c.IsKVM() {
return kvmManager.ResetSSHPassword(id, password)
}
return lxcManager.ResetSSHPassword(id, password)
}
func assignIPv6ByRuntime(id int) (*config.Container, error) {
c := config.FindContainer(id)
if c != nil && c.IsKVM() {
return kvmManager.AssignIPv6(id)
}
return lxcManager.AssignIPv6(id)
}
func usageByRuntime(id int) (map[string]interface{}, error) {
c := config.FindContainer(id)
if c != nil && c.IsKVM() {
return kvmManager.GetResourceUsage(id)
}
return lxcManager.GetResourceUsage(id)
}
func trafficByRuntime(id int) map[string]interface{} {
c := config.FindContainer(id)
if c != nil && c.IsKVM() {
return kvmManager.GetTrafficInfo(id)
}
return lxcManager.GetTrafficInfo(id)
}
func createSnapshotByRuntime(id int, createdBy string, scheduled bool, rotateLimit int) (config.Snapshot, error) {
c := config.FindContainer(id)
if c != nil && c.IsKVM() {
return kvmManager.CreateSnapshot(id, createdBy, scheduled, rotateLimit)
}
return lxcManager.CreateSnapshot(id, createdBy, scheduled, rotateLimit)
}
func deleteSnapshotByRuntime(snapshotID string) error {
snapshot := config.FindSnapshot(snapshotID)
if snapshot != nil {
if c := config.FindContainer(snapshot.ContainerID); c != nil && c.IsKVM() {
return kvmManager.DeleteSnapshot(snapshotID)
}
if strings.Contains(snapshot.Path, string(os.PathSeparator)+"kvm"+string(os.PathSeparator)) {
return kvmManager.DeleteSnapshot(snapshotID)
}
}
return lxcManager.DeleteSnapshot(snapshotID)
}
func restoreSnapshotByRuntime(snapshotID string) error {
snapshot := config.FindSnapshot(snapshotID)
if snapshot != nil {
if c := config.FindContainer(snapshot.ContainerID); c != nil && c.IsKVM() {
return kvmManager.RestoreSnapshot(snapshotID)
}
if strings.Contains(snapshot.Path, string(os.PathSeparator)+"kvm"+string(os.PathSeparator)) {
return kvmManager.RestoreSnapshot(snapshotID)
}
}
return lxcManager.RestoreSnapshot(snapshotID)
}
func setSnapshotScheduleByRuntime(id int, enabled bool, intervalHours int, scheduleTime string, createdBy string) (*config.Container, error) {
c := config.FindContainer(id)
if c != nil && c.IsKVM() {
return kvmManager.SetSnapshotSchedule(id, enabled, intervalHours, scheduleTime, createdBy)
}
return lxcManager.SetSnapshotSchedule(id, enabled, intervalHours, scheduleTime, createdBy)
}
func applyLimitsByRuntime(c *config.Container) error {
if c != nil && c.IsKVM() {
return kvmManager.ApplyContainerLimits(c)
}
return lxcManager.ApplyContainerLimits(c)
}
func listByRuntime() ([]config.Container, error) {
containers, err := lxcManager.ListContainers()
if err != nil {
containers = config.AppConfig.Containers
}
containers = kvmManager.ListContainers(containers)
return containers, err
}
func validateRuntimeResourceRequest(runtime string, vcpu float64, ramMB int, diskGB int) error {
if runtime == config.VirtualizationKVM {
if vcpu < 1 || math.Abs(vcpu-math.Round(vcpu)) > 0.000001 {
return fmt.Errorf("KVM vCPU must be a whole number and at least 1")
}
}
return validateContainerResourceRequest(vcpu, ramMB, diskGB)
}
+173 -17
View File
@@ -7,6 +7,7 @@ import (
"net/http" "net/http"
"os" "os"
"os/exec" "os/exec"
"sort"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
@@ -557,10 +558,10 @@ func countPorts(totalCounts map[int]int, destCounts map[int]map[string]int, port
func (ss *SecurityScanner) addAlert(name, alertType, severity, srcIP, dstIP string, port int, detail, logLine string) { func (ss *SecurityScanner) addAlert(name, alertType, severity, srcIP, dstIP string, port int, detail, logLine string) {
ss.mu.Lock() ss.mu.Lock()
defer ss.mu.Unlock()
now := time.Now() now := time.Now()
cutoff := now.Add(-5 * time.Minute) cutoff := now.Add(-5 * time.Minute)
shouldShutdown := false
for i := range ss.alerts { for i := range ss.alerts {
a := &ss.alerts[i] a := &ss.alerts[i]
@@ -579,6 +580,11 @@ func (ss *SecurityScanner) addAlert(name, alertType, severity, srcIP, dstIP stri
if severityRank(severity) > severityRank(a.Severity) { if severityRank(severity) > severityRank(a.Severity) {
a.Severity = severity a.Severity = severity
} }
shouldShutdown = config.AppConfig.SecurityAutoShutdown
ss.mu.Unlock()
if shouldShutdown {
autoShutdownAlertContainer(name, alertType, severity)
}
return return
} }
@@ -599,10 +605,16 @@ func (ss *SecurityScanner) addAlert(name, alertType, severity, srcIP, dstIP stri
ss.alerts = append(ss.alerts, alert) ss.alerts = append(ss.alerts, alert)
config.AddAuditLog("security_"+alertType, name, fmt.Sprintf("[%s] %s", severity, detail), "system") config.AddAuditLog("security_"+alertType, name, fmt.Sprintf("[%s] %s", severity, detail), "system")
shouldShutdown = config.AppConfig.SecurityAutoShutdown
if len(ss.alerts) > 200 { if len(ss.alerts) > 200 {
ss.alerts = ss.alerts[len(ss.alerts)-200:] ss.alerts = ss.alerts[len(ss.alerts)-200:]
} }
ss.mu.Unlock()
if shouldShutdown {
autoShutdownAlertContainer(name, alertType, severity)
}
} }
func severityRank(severity string) int { func severityRank(severity string) int {
@@ -620,6 +632,22 @@ func severityRank(severity string) int {
} }
} }
func autoShutdownAlertContainer(containerName, alertType, severity string) {
c := config.FindContainerByName(containerName)
if c == nil || c.Status != "running" {
return
}
reason := fmt.Sprintf("%s 告警触发策略临时封禁", alertType)
if severity != "" {
reason = fmt.Sprintf("[%s] %s", severity, reason)
}
config.SetContainerPolicyBlock(c.ID, true, reason)
taskID, queued := globalQueue.EnqueueSecurityStop(c.ID, c.Name)
if queued {
config.AddAuditLog("security_auto_shutdown", c.Name, fmt.Sprintf("[%s] %s 告警触发自动关机任务 %s", severity, alertType, taskID), "system")
}
}
// HandleSecurityAlerts returns all security alerts. // HandleSecurityAlerts returns all security alerts.
func HandleSecurityAlerts(w http.ResponseWriter, r *http.Request) { func HandleSecurityAlerts(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet { if r.Method != http.MethodGet {
@@ -627,19 +655,35 @@ func HandleSecurityAlerts(w http.ResponseWriter, r *http.Request) {
return return
} }
ss := ensureScanner() jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: mergedSecurityAlerts()})
ss.mu.Lock() }
reversed := make([]SecurityAlert, len(ss.alerts))
for i, a := range ss.alerts {
reversed[len(ss.alerts)-1-i] = a
}
ss.mu.Unlock()
if reversed == nil { // HandleSecuritySettings returns or updates security automation settings.
reversed = []SecurityAlert{} func HandleSecuritySettings(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]bool{
"auto_shutdown": config.AppConfig.SecurityAutoShutdown,
}})
case http.MethodPut:
var req struct {
AutoShutdown bool `json:"auto_shutdown"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
return
}
config.AppConfig.SecurityAutoShutdown = req.AutoShutdown
if err := config.SaveConfig(); err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
return
}
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]bool{
"auto_shutdown": config.AppConfig.SecurityAutoShutdown,
}})
default:
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
} }
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: reversed})
} }
// HandleSecurityCheck triggers immediate security check for a container. // HandleSecurityCheck triggers immediate security check for a container.
@@ -738,13 +782,12 @@ func HandleContainerSecuritySummary(w http.ResponseWriter, r *http.Request) {
return return
} }
ss := ensureScanner()
ss.mu.Lock()
critical := 0 critical := 0
high := 0 high := 0
medium := 0 medium := 0
low := 0 low := 0
for _, a := range ss.alerts { alerts := mergedSecurityAlerts()
for _, a := range alerts {
switch a.Severity { switch a.Severity {
case "critical": case "critical":
critical++ critical++
@@ -756,8 +799,7 @@ func HandleContainerSecuritySummary(w http.ResponseWriter, r *http.Request) {
low++ low++
} }
} }
total := len(ss.alerts) total := len(alerts)
ss.mu.Unlock()
summary := map[string]interface{}{ summary := map[string]interface{}{
"total_alerts": total, "total_alerts": total,
@@ -769,3 +811,117 @@ func HandleContainerSecuritySummary(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: summary}) jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: summary})
} }
func mergedSecurityAlerts() []SecurityAlert {
ss := ensureScanner()
ss.mu.Lock()
alerts := make([]SecurityAlert, len(ss.alerts))
copy(alerts, ss.alerts)
ss.mu.Unlock()
seen := make(map[string]bool)
for _, alert := range alerts {
seen[securityAlertKey(alert)] = true
}
for i, log := range config.AppConfig.AuditLogs {
alert, ok := alertFromSecurityAuditLog(log, i)
if !ok {
continue
}
key := securityAlertKey(alert)
if seen[key] {
continue
}
seen[key] = true
alerts = append(alerts, alert)
}
sort.SliceStable(alerts, func(i, j int) bool {
ti, errI := time.Parse("2006-01-02 15:04:05", alerts[i].Timestamp)
tj, errJ := time.Parse("2006-01-02 15:04:05", alerts[j].Timestamp)
if errI == nil && errJ == nil && !ti.Equal(tj) {
return ti.After(tj)
}
return alerts[i].Timestamp > alerts[j].Timestamp
})
if len(alerts) > 200 {
alerts = alerts[:200]
}
if alerts == nil {
return []SecurityAlert{}
}
return alerts
}
func securityAlertKey(alert SecurityAlert) string {
return strings.Join([]string{
alert.Timestamp,
alert.ContainerName,
alert.Type,
alert.Detail,
strconv.Itoa(alert.TargetPort),
}, "\x1f")
}
func alertFromSecurityAuditLog(log config.AuditLog, index int) (SecurityAlert, bool) {
if !strings.HasPrefix(log.Action, "security_") || log.Action == "security_auto_shutdown" || log.Action == "security_policy_unblock" {
return SecurityAlert{}, false
}
alertType := strings.TrimPrefix(log.Action, "security_")
severity, detail := parseSecurityAuditDetail(log.Detail)
targetPort := parseDetailPort(detail)
targetIP := ""
if targetPort > 0 || alertType == "horizontal_scan" || alertType == "brute_force" {
targetIP = "*"
}
return SecurityAlert{
ID: fmt.Sprintf("audit-security-%d", index),
ContainerName: log.Target,
Type: alertType,
Severity: severity,
SourceIP: "",
TargetIP: targetIP,
TargetPort: targetPort,
Detail: detail,
LogLine: "",
Timestamp: log.Time,
Count: 1,
}, true
}
func parseSecurityAuditDetail(detail string) (string, string) {
severity := "medium"
if strings.HasPrefix(detail, "[") {
if end := strings.Index(detail, "]"); end > 1 {
severity = detail[1:end]
detail = strings.TrimSpace(detail[end+1:])
}
}
return severity, detail
}
func parseDetailPort(detail string) int {
for _, marker := range []string{"端口 ", "端口"} {
idx := strings.Index(detail, marker)
if idx == -1 {
continue
}
start := idx + len(marker)
for start < len(detail) && (detail[start] == ' ' || detail[start] == ':' || detail[start] == '(') {
start++
}
end := start
for end < len(detail) && detail[end] >= '0' && detail[end] <= '9' {
end++
}
if end > start {
port, _ := strconv.Atoi(detail[start:end])
return port
}
}
return 0
}
+4 -4
View File
@@ -74,7 +74,7 @@ func createContainerSnapshot(w http.ResponseWriter, r *http.Request, containerID
return return
} }
} }
snapshot, err := lxcManager.CreateSnapshot(containerID, user, false, 0) snapshot, err := createSnapshotByRuntime(containerID, user, false, 0)
if err != nil { if err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()}) jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
return return
@@ -138,7 +138,7 @@ func updateSnapshotSchedule(w http.ResponseWriter, r *http.Request, containerID
req.Time = "03:00" req.Time = "03:00"
} }
user := requestUser(r) user := requestUser(r)
c, err := lxcManager.SetSnapshotSchedule(containerID, req.Enabled, req.IntervalHours, req.Time, user) c, err := setSnapshotScheduleByRuntime(containerID, req.Enabled, req.IntervalHours, req.Time, user)
if err != nil { if err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()}) jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
return return
@@ -162,7 +162,7 @@ func deleteContainerSnapshot(w http.ResponseWriter, r *http.Request, containerID
return return
} }
user := requestUser(r) user := requestUser(r)
if err := lxcManager.DeleteSnapshot(snapshotID); err != nil { if err := deleteSnapshotByRuntime(snapshotID); err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()}) jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
return return
} }
@@ -177,7 +177,7 @@ func restoreContainerSnapshot(w http.ResponseWriter, r *http.Request, containerI
return return
} }
user := requestUser(r) user := requestUser(r)
if err := lxcManager.RestoreSnapshot(snapshotID); err != nil { if err := restoreSnapshotByRuntime(snapshotID); err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()}) jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
return return
} }
+79 -26
View File
@@ -27,6 +27,7 @@ type terminalResizeMessage struct {
type webSSHTicket struct { type webSSHTicket struct {
ContainerName string ContainerName string
SubUser bool
ExpiresAt time.Time ExpiresAt time.Time
} }
@@ -52,16 +53,22 @@ func HandleWebSSHTicket(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Access denied to this container"}) jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Access denied to this container"})
return return
} }
if config.FindContainerByName(req.ContainerName) == nil { c := config.FindContainerByName(req.ContainerName)
if c == nil {
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"}) jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"})
return return
} }
if isSubUserRequest(r) && c.PolicyBlocked {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: policyBlockedMessage(c)})
return
}
ticket := randomHex(32) ticket := randomHex(32)
webSSHTickets.Lock() webSSHTickets.Lock()
cleanupExpiredWebSSHTicketsLocked(time.Now()) cleanupExpiredWebSSHTicketsLocked(time.Now())
webSSHTickets.items[ticket] = webSSHTicket{ webSSHTickets.items[ticket] = webSSHTicket{
ContainerName: req.ContainerName, ContainerName: req.ContainerName,
SubUser: isSubUserRequest(r),
ExpiresAt: time.Now().Add(60 * time.Second), ExpiresAt: time.Now().Add(60 * time.Second),
} }
webSSHTickets.Unlock() webSSHTickets.Unlock()
@@ -86,7 +93,8 @@ func HandleWebSSH(w http.ResponseWriter, r *http.Request) {
return return
} }
if !consumeWebSSHTicket(ticket, containerName) { item, ok := consumeWebSSHTicket(ticket, containerName)
if !ok {
http.Error(w, "invalid or expired ticket", http.StatusUnauthorized) http.Error(w, "invalid or expired ticket", http.StatusUnauthorized)
return return
} }
@@ -96,21 +104,39 @@ func HandleWebSSH(w http.ResponseWriter, r *http.Request) {
http.Error(w, "container not found", http.StatusNotFound) http.Error(w, "container not found", http.StatusNotFound)
return return
} }
if item.SubUser && c.PolicyBlocked {
http.Error(w, "虚拟机被策略临时封禁", http.StatusForbidden)
return
}
if c.Status != "running" { if c.Status != "running" {
http.Error(w, "container is not running", http.StatusBadRequest) http.Error(w, "container is not running", http.StatusBadRequest)
return return
} }
if c.IP == "" { if c.IP == "" {
if ip, err := lxcManager.GetContainerIP(c.LxcName()); err == nil { var ip string
var err error
if c.IsKVM() {
ip, err = kvmManager.GetContainerIP(c.VirshName())
} else {
ip, err = lxcManager.GetContainerIP(c.LxcName())
}
if err == nil {
c.IP = ip c.IP = ip
config.SaveConfig() config.SaveConfig()
} }
} }
if c.IP == "" { if c.IP == "" && !c.IsKVM() {
if ip, err := lxcManager.EnsureContainerIPv4(c.ID); err == nil && ip != "" { if ip, err := lxcManager.EnsureContainerIPv4(c.ID); err == nil && ip != "" {
c.IP = ip c.IP = ip
} }
} }
if c.IP == "" && c.IsKVM() {
if err := kvmManager.EnsureSSH(c.ID); err == nil {
if refreshed := config.FindContainer(c.ID); refreshed != nil {
c = refreshed
}
}
}
if c.IP == "" { if c.IP == "" {
http.Error(w, "container ip is not available", http.StatusBadRequest) http.Error(w, "container ip is not available", http.StatusBadRequest)
return return
@@ -127,6 +153,10 @@ func HandleWebSSH(w http.ResponseWriter, r *http.Request) {
defer ws.Close() defer ws.Close()
if c.SSHPassword == "" { if c.SSHPassword == "" {
if c.IsKVM() {
writeWebSocketText(ws, nil, "\r\nKVM SSH password is not available. Reinstall or reset after SSH is ready.\r\n")
return
}
writeWebSocketText(ws, nil, "\r\nPreparing SSH service. This can take up to 90 seconds on first boot...\r\n") writeWebSocketText(ws, nil, "\r\nPreparing SSH service. This can take up to 90 seconds on first boot...\r\n")
if err := lxcManager.EnsureSSH(c.ID); err != nil { if err := lxcManager.EnsureSSH(c.ID); err != nil {
writeWebSocketText(ws, nil, fmt.Sprintf("\r\nSSH auto setup failed: %v\r\n", err)) writeWebSocketText(ws, nil, fmt.Sprintf("\r\nSSH auto setup failed: %v\r\n", err))
@@ -154,25 +184,48 @@ func HandleWebSSH(w http.ResponseWriter, r *http.Request) {
writeWebSocketText(ws, nil, fmt.Sprintf("Connecting to %s...\r\n", addr)) writeWebSocketText(ws, nil, fmt.Sprintf("Connecting to %s...\r\n", addr))
client, err := ssh.Dial("tcp", addr, sshConfig) client, err := ssh.Dial("tcp", addr, sshConfig)
if err != nil { if err != nil {
writeWebSocketText(ws, nil, "\r\nSSH is not ready yet, preparing service. This can take up to 90 seconds on first boot...\r\n") if c.IsKVM() {
if setupErr := lxcManager.EnsureSSH(c.ID); setupErr != nil { writeWebSocketText(ws, nil, "\r\nSSH is not ready yet, preparing KVM guest service. This can take a few minutes on first boot...\r\n")
writeWebSocketText(ws, nil, fmt.Sprintf("\r\nSSH auto setup failed: %v\r\n", setupErr)) if setupErr := kvmManager.EnsureSSH(c.ID); setupErr != nil {
return writeWebSocketText(ws, nil, fmt.Sprintf("\r\nKVM SSH auto setup failed: %v\r\n", setupErr))
} return
if refreshed := config.FindContainer(c.ID); refreshed != nil { }
c = refreshed if refreshed := config.FindContainer(c.ID); refreshed != nil {
} c = refreshed
if ip, ipErr := lxcManager.GetContainerIP(c.LxcName()); ipErr == nil && ip != "" { }
c.IP = ip if ip, ipErr := kvmManager.GetContainerIP(c.VirshName()); ipErr == nil && ip != "" {
config.SaveConfig() c.IP = ip
addr = net.JoinHostPort(c.IP, "22") config.SaveConfig()
} addr = net.JoinHostPort(c.IP, "22")
sshConfig.Auth = []ssh.AuthMethod{ssh.Password(c.SSHPassword)} }
sshConfig.Timeout = 10 * time.Second sshConfig.Auth = []ssh.AuthMethod{ssh.Password(c.SSHPassword)}
client, err = ssh.Dial("tcp", addr, sshConfig) sshConfig.Timeout = 10 * time.Second
if err != nil { client, err = ssh.Dial("tcp", addr, sshConfig)
writeWebSocketText(ws, nil, fmt.Sprintf("\r\nWebSSH connection failed: %v\r\n", err)) if err != nil {
return writeWebSocketText(ws, nil, fmt.Sprintf("\r\nWebSSH connection failed: %v\r\n", err))
return
}
} else {
writeWebSocketText(ws, nil, "\r\nSSH is not ready yet, preparing service. This can take up to 90 seconds on first boot...\r\n")
if setupErr := lxcManager.EnsureSSH(c.ID); setupErr != nil {
writeWebSocketText(ws, nil, fmt.Sprintf("\r\nSSH auto setup failed: %v\r\n", setupErr))
return
}
if refreshed := config.FindContainer(c.ID); refreshed != nil {
c = refreshed
}
if ip, ipErr := lxcManager.GetContainerIP(c.LxcName()); ipErr == nil && ip != "" {
c.IP = ip
config.SaveConfig()
addr = net.JoinHostPort(c.IP, "22")
}
sshConfig.Auth = []ssh.AuthMethod{ssh.Password(c.SSHPassword)}
sshConfig.Timeout = 10 * time.Second
client, err = ssh.Dial("tcp", addr, sshConfig)
if err != nil {
writeWebSocketText(ws, nil, fmt.Sprintf("\r\nWebSSH connection failed: %v\r\n", err))
return
}
} }
} }
defer client.Close() defer client.Close()
@@ -318,17 +371,17 @@ func writeWebSocketText(ws *websocket.Conn, writeMu *sync.Mutex, msg string) {
_ = ws.WriteMessage(websocket.TextMessage, []byte(msg)) _ = ws.WriteMessage(websocket.TextMessage, []byte(msg))
} }
func consumeWebSSHTicket(ticket, containerName string) bool { func consumeWebSSHTicket(ticket, containerName string) (webSSHTicket, bool) {
now := time.Now() now := time.Now()
webSSHTickets.Lock() webSSHTickets.Lock()
defer webSSHTickets.Unlock() defer webSSHTickets.Unlock()
cleanupExpiredWebSSHTicketsLocked(now) cleanupExpiredWebSSHTicketsLocked(now)
item, ok := webSSHTickets.items[ticket] item, ok := webSSHTickets.items[ticket]
if !ok { if !ok {
return false return webSSHTicket{}, false
} }
delete(webSSHTickets.items, ticket) delete(webSSHTickets.items, ticket)
return item.ContainerName == containerName && now.Before(item.ExpiresAt) return item, item.ContainerName == containerName && now.Before(item.ExpiresAt)
} }
func cleanupExpiredWebSSHTicketsLocked(now time.Time) { func cleanupExpiredWebSSHTicketsLocked(now time.Time) {
+24 -1
View File
@@ -352,7 +352,11 @@ func SubUserMiddleware(next http.HandlerFunc) http.HandlerFunc {
} }
action := "" action := ""
if len(parts) > 1 { if len(parts) > 1 {
action = parts[1] action = strings.Join(parts[1:], "/")
}
if c.PolicyBlocked && isSubUserBlockedAction(action, r.Method) {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: policyBlockedMessage(c)})
return
} }
if !isSubUserContainerActionAllowed(action, r.Method) { if !isSubUserContainerActionAllowed(action, r.Method) {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Action is not allowed for this link"}) jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Action is not allowed for this link"})
@@ -412,6 +416,25 @@ func isContainerAllowed(allowed subUserAccess, c *config.Container) bool {
return c != nil && c.UUID != "" && allowed.uuids[c.UUID] return c != nil && c.UUID != "" && allowed.uuids[c.UUID]
} }
func isSubUserBlockedAction(action string, method string) bool {
if action == "" {
return method != http.MethodGet
}
switch action {
case "usage", "traffic":
return method != http.MethodGet
default:
return true
}
}
func policyBlockedMessage(c *config.Container) string {
if c != nil && c.PolicyBlockedReason != "" {
return "虚拟机被策略临时封禁:" + c.PolicyBlockedReason
}
return "虚拟机被策略临时封禁"
}
func isSubUserContainerActionAllowed(action string, method string) bool { func isSubUserContainerActionAllowed(action string, method string) bool {
if action == "" { if action == "" {
return method == http.MethodGet return method == http.MethodGet
+49 -11
View File
@@ -196,6 +196,24 @@ func (q *TaskQueue) enqueueSingleWithAudit(containerID int, containerName string
return task.ID return task.ID
} }
func (q *TaskQueue) EnqueueSecurityStop(containerID int, containerName string) (string, bool) {
q.mu.Lock()
defer q.mu.Unlock()
for _, task := range q.tasks {
if task.Type != TaskStop || task.ContainerID != containerID {
continue
}
if task.Status == "pending" || task.Status == "running" {
return task.ID, false
}
}
taskID := q.enqueueSingleWithAudit(containerID, containerName, TaskStop, "", "system:security", "", "")
q.persistTasks()
return taskID, true
}
// createWorker handles TaskCreate: lxc-create, resource setup, start, and SSH init. // createWorker handles TaskCreate: lxc-create, resource setup, start, and SSH init.
// If a restored task already has a same-name container in config, it resumes // If a restored task already has a same-name container in config, it resumes
// initialization instead of creating another ct-{id}. // initialization instead of creating another ct-{id}.
@@ -226,7 +244,7 @@ func (q *TaskQueue) createWorker() {
c := config.FindContainerByName(task.Config.Name) c := config.FindContainerByName(task.Config.Name)
if c == nil { if c == nil {
// 1) Download image + apply limits (lxc-create) // 1) Download image + apply limits (lxc-create)
err := lxcManager.CreateContainer(task.Config) err := createByRuntime(task.Config)
if err != nil { if err != nil {
task.Status = "failed" task.Status = "failed"
task.Error = err.Error() task.Error = err.Error()
@@ -256,10 +274,10 @@ func (q *TaskQueue) createWorker() {
// 3) Start + initialize SSH/network in the same worker. // 3) Start + initialize SSH/network in the same worker.
// If init fails, destroy the container so no dead entry remains. // If init fails, destroy the container so no dead entry remains.
startErr := lxcManager.StartContainer(c.ID) startErr := startByRuntime(c.ID)
if startErr != nil { if startErr != nil {
if createdByTask { if createdByTask {
lxcManager.DestroyContainer(c.ID) _ = destroyByRuntime(c.ID)
} }
task.Status = "failed" task.Status = "failed"
task.Error = startErr.Error() task.Error = startErr.Error()
@@ -304,13 +322,13 @@ func (q *TaskQueue) opWorker() {
if err == nil { if err == nil {
switch task.Type { switch task.Type {
case TaskStart: case TaskStart:
err = lxcManager.StartContainer(task.ContainerID) err = startByRuntime(task.ContainerID)
case TaskStop: case TaskStop:
err = lxcManager.StopContainer(task.ContainerID) err = stopByRuntime(task.ContainerID)
case TaskRestart: case TaskRestart:
err = lxcManager.RestartContainer(task.ContainerID) err = restartByRuntime(task.ContainerID)
case TaskDelete: case TaskDelete:
err = lxcManager.DestroyContainer(task.ContainerID) err = destroyByRuntime(task.ContainerID)
if err == nil { if err == nil {
time.Sleep(1 * time.Second) time.Sleep(1 * time.Second)
if config.FindContainer(task.ContainerID) != nil { if config.FindContainer(task.ContainerID) != nil {
@@ -318,7 +336,7 @@ func (q *TaskQueue) opWorker() {
} }
} }
case TaskReinstall: case TaskReinstall:
err = lxcManager.ReinstallContainer(task.ContainerID, task.TemplateID) err = reinstallByRuntime(task.ContainerID, task.TemplateID)
} }
} }
@@ -337,10 +355,14 @@ func (q *TaskQueue) opWorker() {
switch task.Type { switch task.Type {
case TaskStart: case TaskStart:
config.UpdateContainerStatus(task.ContainerID, "running") config.UpdateContainerStatus(task.ContainerID, "running")
clearPolicyBlockAfterAdminRecovery(task)
case TaskStop: case TaskStop:
config.UpdateContainerStatus(task.ContainerID, "stopped") config.UpdateContainerStatus(task.ContainerID, "stopped")
case TaskRestart: case TaskRestart:
config.UpdateContainerStatus(task.ContainerID, "running") config.UpdateContainerStatus(task.ContainerID, "running")
clearPolicyBlockAfterAdminRecovery(task)
case TaskReinstall:
clearPolicyBlockAfterAdminRecovery(task)
} }
} }
q.persistTasks() q.persistTasks()
@@ -348,6 +370,17 @@ func (q *TaskQueue) opWorker() {
} }
} }
func clearPolicyBlockAfterAdminRecovery(task *Task) {
if task == nil || strings.HasPrefix(task.User, "user:") || task.User == "system:security" {
return
}
c := config.FindContainer(task.ContainerID)
if c != nil && c.PolicyBlocked {
config.SetContainerPolicyBlock(c.ID, false, "")
config.AddAuditLog("security_policy_unblock", c.Name, "管理员操作后解除策略临时封禁", task.User)
}
}
func resolveTaskContainer(task *Task) error { func resolveTaskContainer(task *Task) error {
if task.Type == TaskCreate { if task.Type == TaskCreate {
return nil return nil
@@ -456,7 +489,11 @@ func HandleSingleTaskAction(w http.ResponseWriter, r *http.Request, id int, acti
templateID = c.Template templateID = c.Template
} }
} }
if !isTemplateEnabledAndDownloaded(templateID) { runtime := runtimeFromTemplateID(templateID)
if c := config.FindContainer(id); c != nil {
runtime = c.Runtime()
}
if !isImageEnabledAndDownloaded(templateID, runtime) {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Template is not enabled or downloaded"}) jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Template is not enabled or downloaded"})
return return
} }
@@ -516,13 +553,14 @@ func HandleBatchCreate(w http.ResponseWriter, r *http.Request) {
if req.Containers[i].VCPU <= 0 { if req.Containers[i].VCPU <= 0 {
req.Containers[i].VCPU = 1 req.Containers[i].VCPU = 1
} }
req.Containers[i].Virtualization = runtimeFromRequest(req.Containers[i].Virtualization)
if req.Containers[i].RAMMB < 128 { if req.Containers[i].RAMMB < 128 {
req.Containers[i].RAMMB = 512 req.Containers[i].RAMMB = 512
} }
if req.Containers[i].DiskGB < 1 { if req.Containers[i].DiskGB < 1 {
req.Containers[i].DiskGB = 5 req.Containers[i].DiskGB = 5
} }
if !isTemplateEnabledAndDownloaded(req.Containers[i].TemplateID) { if !isImageEnabledAndDownloaded(req.Containers[i].TemplateID, req.Containers[i].Virtualization) {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: name + ": template is not enabled or downloaded"}) jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: name + ": template is not enabled or downloaded"})
return return
} }
@@ -532,7 +570,7 @@ func HandleBatchCreate(w http.ResponseWriter, r *http.Request) {
if req.Containers[i].SnapshotLimit <= 0 { if req.Containers[i].SnapshotLimit <= 0 {
req.Containers[i].SnapshotLimit = config.DefaultSnapshotLimit req.Containers[i].SnapshotLimit = config.DefaultSnapshotLimit
} }
if err := validateContainerResourceRequest(req.Containers[i].VCPU, req.Containers[i].RAMMB, req.Containers[i].DiskGB); err != nil { if err := validateRuntimeResourceRequest(req.Containers[i].Virtualization, req.Containers[i].VCPU, req.Containers[i].RAMMB, req.Containers[i].DiskGB); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: name + ": " + err.Error()}) jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: name + ": " + err.Error()})
return return
} }
+262
View File
@@ -0,0 +1,262 @@
package api
import (
"encoding/json"
"fmt"
"io"
"log"
"net"
"net/http"
"sync"
"time"
"clicd/internal/config"
"github.com/gorilla/websocket"
)
type webVNCTicket struct {
ContainerName string
ContainerUUID string
Username string
SubUser bool
ClientIP string
UserAgent string
ExpiresAt time.Time
}
var webVNCTickets = struct {
sync.Mutex
items map[string]webVNCTicket
}{items: map[string]webVNCTicket{}}
func HandleVNCTicket(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
var req struct {
ContainerName string `json:"container_name"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.ContainerName == "" {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Container name required"})
return
}
if !isContainerAllowedForRequest(r, req.ContainerName) {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Access denied to this container"})
return
}
c := config.FindContainerByName(req.ContainerName)
if c == nil {
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"})
return
}
if isSubUserRequest(r) && c.PolicyBlocked {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: policyBlockedMessage(c)})
return
}
if !c.IsKVM() {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "VNC console is only available for KVM VMs"})
return
}
username, isSubUser := vncRequesterIdentity(r)
ticket := randomHex(32)
webVNCTickets.Lock()
cleanupExpiredWebVNCTicketsLocked(time.Now())
webVNCTickets.items[ticket] = webVNCTicket{
ContainerName: c.Name,
ContainerUUID: c.UUID,
Username: username,
SubUser: isSubUser,
ClientIP: clientIP(r),
UserAgent: r.UserAgent(),
ExpiresAt: time.Now().Add(60 * time.Second),
}
webVNCTickets.Unlock()
jsonResponse(w, http.StatusOK, APIResponse{
Success: true,
Data: map[string]string{"ticket": ticket},
})
}
// HandleVNCProxy proxies a KVM VM's local libvirt VNC socket to the browser.
func HandleVNCProxy(w http.ResponseWriter, r *http.Request) {
ticket := webVNCTicketFromRequest(r)
if ticket == "" {
http.Error(w, "ticket required", http.StatusUnauthorized)
return
}
containerName := r.URL.Query().Get("container")
if containerName == "" {
http.Error(w, "container name required", http.StatusBadRequest)
return
}
item, ok := consumeWebVNCTicket(ticket, containerName, r)
if !ok {
http.Error(w, "invalid or expired ticket", http.StatusUnauthorized)
return
}
c := config.FindContainerByName(containerName)
if c == nil || c.UUID != item.ContainerUUID {
http.Error(w, "container not found", http.StatusNotFound)
return
}
if item.SubUser && c.PolicyBlocked {
http.Error(w, "虚拟机被策略临时封禁", http.StatusForbidden)
return
}
if !c.IsKVM() {
http.Error(w, "VNC console is only available for KVM VMs", http.StatusBadRequest)
return
}
if c.Status != "running" {
http.Error(w, "container is not running", http.StatusBadRequest)
return
}
vncPort, err := kvmManager.RefreshVNCPort(c.ID)
if err != nil {
http.Error(w, fmt.Sprintf("VNC display is not available: %v", err), http.StatusBadRequest)
return
}
vncConn, err := net.DialTimeout("tcp", net.JoinHostPort("127.0.0.1", fmt.Sprintf("%d", vncPort)), 5*time.Second)
if err != nil {
http.Error(w, fmt.Sprintf("VNC connection failed: %v", err), http.StatusBadRequest)
return
}
defer vncConn.Close()
responseHeader := http.Header{}
if protocol := webVNCResponseProtocol(r); protocol != "" {
responseHeader.Set("Sec-WebSocket-Protocol", protocol)
}
ws, err := upgrader.Upgrade(w, r, responseHeader)
if err != nil {
log.Printf("WebVNC upgrade failed: %v", err)
return
}
defer ws.Close()
log.Printf("WebVNC connected for container %s as %s (sub_user=%t) -> 127.0.0.1:%d", containerName, item.Username, item.SubUser, vncPort)
done := make(chan string, 2)
var writeMu sync.Mutex
go streamVNCToWebSocket(ws, &writeMu, vncConn, done)
go streamWebSocketToVNC(ws, vncConn, done)
reason := <-done
_ = vncConn.Close()
_ = ws.Close()
log.Printf("WebVNC disconnected for container %s as %s: %s", containerName, item.Username, reason)
}
func vncRequesterIdentity(r *http.Request) (string, bool) {
claims, ok := claimsFromRequest(r)
if !ok {
return "api-key", false
}
if subUser, ok := claims["sub_user"].(string); ok && subUser != "" {
return subUser, true
}
if username, ok := claims["username"].(string); ok && username != "" {
return username, false
}
return "unknown", false
}
func webVNCTicketFromRequest(r *http.Request) string {
for _, protocol := range websocket.Subprotocols(r) {
const prefix = "clicd-vnc-ticket."
if len(protocol) > len(prefix) && protocol[:len(prefix)] == prefix {
return protocol[len(prefix):]
}
}
return r.URL.Query().Get("ticket")
}
func webVNCResponseProtocol(r *http.Request) string {
for _, protocol := range websocket.Subprotocols(r) {
if protocol == "binary" {
return protocol
}
}
for _, protocol := range websocket.Subprotocols(r) {
const prefix = "clicd-vnc-ticket."
if len(protocol) > len(prefix) && protocol[:len(prefix)] == prefix {
return protocol
}
}
return ""
}
func consumeWebVNCTicket(ticket, containerName string, r *http.Request) (webVNCTicket, bool) {
now := time.Now()
webVNCTickets.Lock()
defer webVNCTickets.Unlock()
cleanupExpiredWebVNCTicketsLocked(now)
item, ok := webVNCTickets.items[ticket]
if !ok {
return webVNCTicket{}, false
}
delete(webVNCTickets.items, ticket)
return item, item.ContainerName == containerName &&
item.ClientIP == clientIP(r) &&
item.UserAgent == r.UserAgent() &&
now.Before(item.ExpiresAt)
}
func cleanupExpiredWebVNCTicketsLocked(now time.Time) {
for ticket, item := range webVNCTickets.items {
if !now.Before(item.ExpiresAt) {
delete(webVNCTickets.items, ticket)
}
}
}
func streamVNCToWebSocket(ws *websocket.Conn, writeMu *sync.Mutex, src io.Reader, done chan<- string) {
buf := make([]byte, 32*1024)
for {
n, err := src.Read(buf)
if n > 0 {
writeMu.Lock()
writeErr := ws.WriteMessage(websocket.BinaryMessage, buf[:n])
writeMu.Unlock()
if writeErr != nil {
done <- fmt.Sprintf("browser websocket write failed: %v", writeErr)
return
}
}
if err != nil {
if err == io.EOF {
done <- "VNC server closed connection"
} else {
done <- fmt.Sprintf("VNC server read failed: %v", err)
}
return
}
}
}
func streamWebSocketToVNC(ws *websocket.Conn, dst net.Conn, done chan<- string) {
for {
messageType, msg, err := ws.ReadMessage()
if err != nil {
done <- fmt.Sprintf("browser websocket read failed: %v", err)
return
}
if messageType != websocket.BinaryMessage && messageType != websocket.TextMessage {
continue
}
if _, err := dst.Write(msg); err != nil {
done <- fmt.Sprintf("VNC server write failed: %v", err)
return
}
}
}
+242 -13
View File
@@ -20,6 +20,11 @@ import (
var manager = lxc.NewManager() var manager = lxc.NewManager()
const (
clicdBackupDir = "/root/clicd-backups"
clicdNewBinaryPath = "/usr/local/bin/clicd.new"
)
// Run starts the CLI interface. // Run starts the CLI interface.
func Run() { func Run() {
reader := bufio.NewReader(os.Stdin) reader := bufio.NewReader(os.Stdin)
@@ -198,11 +203,18 @@ func cliCreateContainer(reader *bufio.Reader) {
container := config.FindContainerByName(name) container := config.FindContainerByName(name)
fmt.Printf("容器 %s 创建成功\n", name) fmt.Printf("容器 %s 创建成功\n", name)
if container != nil { if container != nil {
fmt.Printf("SSH: root / %s, port %d -> 22\n", container.SSHPassword, container.SSHPort) fmt.Print(formatSSHAccess(container.SSHPort))
} }
restartWebPanelForConfigChange() restartWebPanelForConfigChange()
} }
func formatSSHAccess(sshPort int) string {
if sshPort <= 0 {
return "SSH: root, 端口未分配。密码已保存,请在 Web 面板中查看或重置。\n"
}
return fmt.Sprintf("SSH: root, port %d -> 22。密码已保存,请在 Web 面板中查看或重置。\n", sshPort)
}
func cliStartContainer(reader *bufio.Reader) { func cliStartContainer(reader *bufio.Reader) {
id, name := selectContainer(reader, "开机") id, name := selectContainer(reader, "开机")
if id == 0 { if id == 0 {
@@ -532,13 +544,14 @@ func upgradeFromReleaseAsset(assetURL, latest string) error {
return err return err
} }
backupDir := "/root/clicd-backups" backupDir := clicdBackupDir
if err := os.MkdirAll(backupDir, 0700); err != nil { if err := os.MkdirAll(backupDir, 0700); err != nil {
return err return err
} }
backupPath := filepath.Join(backupDir, fmt.Sprintf("clicd.%s.%s", strings.TrimPrefix(latest, "v"), time.Now().Format("20060102-150405"))) backupName := fmt.Sprintf("clicd.%s.%s", safeReleaseBackupComponent(latest), time.Now().Format("20060102-150405"))
if _, err := os.Stat("/usr/local/bin/clicd"); err == nil { if _, err := os.Stat("/usr/local/bin/clicd"); err == nil {
if err := copyFile("/usr/local/bin/clicd", backupPath, 0755); err != nil { backupPath, err := copyFileToBackup("/usr/local/bin/clicd", backupName, 0755)
if err != nil {
return fmt.Errorf("备份旧二进制失败: %w", err) return fmt.Errorf("备份旧二进制失败: %w", err)
} }
fmt.Printf("旧版本已备份: %s\n", backupPath) fmt.Printf("旧版本已备份: %s\n", backupPath)
@@ -548,8 +561,8 @@ func upgradeFromReleaseAsset(assetURL, latest string) error {
if err := stopService("clicd"); err != nil { if err := stopService("clicd"); err != nil {
fmt.Printf("停止 Web 服务失败,继续尝试替换: %v\n", err) fmt.Printf("停止 Web 服务失败,继续尝试替换: %v\n", err)
} }
tmpBin := "/usr/local/bin/clicd.new" tmpBin := clicdNewBinaryPath
if err := copyFile(newBinary, tmpBin, 0755); err != nil { if err := copyFileToUpgradeTemp(newBinary, 0755); err != nil {
return err return err
} }
if err := os.Rename(tmpBin, "/usr/local/bin/clicd"); err != nil { if err := os.Rename(tmpBin, "/usr/local/bin/clicd"); err != nil {
@@ -614,25 +627,69 @@ func findFile(root, name string) (string, error) {
return found, nil return found, nil
} }
func copyFile(src, dst string, mode os.FileMode) error { func copyFileToBackup(src, fileName string, mode os.FileMode) (string, error) {
if fileName == "" || strings.Contains(fileName, "/") || strings.Contains(fileName, "\\") || strings.Contains(fileName, "..") {
return "", fmt.Errorf("unsafe backup file name: %s", fileName)
}
dst := filepath.Join(clicdBackupDir, fileName)
out, err := os.OpenFile(dst, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, mode)
if err != nil {
return "", err
}
if err := copyIntoOpenFile(src, out, mode); err != nil {
return "", err
}
return dst, nil
}
func copyFileToUpgradeTemp(src string, mode os.FileMode) error {
out, err := os.OpenFile(clicdNewBinaryPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, mode)
if err != nil {
return err
}
return copyIntoOpenFile(src, out, mode)
}
func copyIntoOpenFile(src string, out *os.File, mode os.FileMode) error {
in, err := os.Open(src) in, err := os.Open(src)
if err != nil { if err != nil {
out.Close()
return err return err
} }
defer in.Close() defer in.Close()
out, err := os.OpenFile(dst, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, mode) if _, err := io.Copy(out, in); err != nil {
if err != nil { out.Close()
return err return err
} }
if _, err := io.Copy(out, in); err != nil { if err := out.Chmod(mode); err != nil {
out.Close() out.Close()
return err return err
} }
if err := out.Close(); err != nil { if err := out.Close(); err != nil {
return err return err
} }
return os.Chmod(dst, mode) return nil
}
func safeReleaseBackupComponent(tag string) string {
tag = strings.TrimPrefix(strings.TrimSpace(tag), "v")
var b strings.Builder
for _, r := range tag {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '.' || r == '_' || r == '-' {
b.WriteRune(r)
continue
}
b.WriteByte('_')
}
component := strings.Trim(b.String(), "._-")
if component == "" {
return "unknown"
}
if len(component) > 64 {
return component[:64]
}
return component
} }
func sameVersion(current, latest string) bool { func sameVersion(current, latest string) bool {
@@ -681,7 +738,7 @@ func cliImportExistingContainers() {
func cliUninstall(reader *bufio.Reader) { func cliUninstall(reader *bufio.Reader) {
fmt.Println("\n--- 卸载 CLICD ---") fmt.Println("\n--- 卸载 CLICD ---")
fmt.Println("将删除 CLICD 服务和 /usr/local/bin/clicd。") fmt.Println("将删除 CLICD 服务和 /usr/local/bin/clicd。")
fmt.Println("同时会删除 /root/.clicd、/var/lib/lxc 下全部 LXC 容器,以及 /var/cache/lxc 镜像缓存。") fmt.Println("同时会删除 /root/.clicd、/var/lib/lxc、/var/lib/clicd、镜像缓存、备份、临时文件、/swapfile 和 CLICD 网络规则。")
if os.Geteuid() != 0 { if os.Geteuid() != 0 {
fmt.Println("卸载需要 root 权限。") fmt.Println("卸载需要 root 权限。")
@@ -696,6 +753,10 @@ func cliUninstall(reader *bufio.Reader) {
} }
destroyAllLXCContainers() destroyAllLXCContainers()
destroyAllKVMDomains()
cleanupCLICDNetworking()
removeCLICDHostHooks()
removeCLICDQuotaRecords()
stopAndRemoveService() stopAndRemoveService()
removePath("/usr/local/bin/clicd") removePath("/usr/local/bin/clicd")
removePath("/etc/sysctl.d/99-clicd.conf") removePath("/etc/sysctl.d/99-clicd.conf")
@@ -703,13 +764,18 @@ func cliUninstall(reader *bufio.Reader) {
removePath("/var/log/clicd.err") removePath("/var/log/clicd.err")
removePath("/root/.clicd") removePath("/root/.clicd")
removePath("/var/lib/lxc") removePath("/var/lib/lxc")
removePath("/var/lib/clicd")
removePath("/var/cache/lxc") removePath("/var/cache/lxc")
removePath("/var/cache/clicd")
removePath("/root/clicd-backups")
removeCLICDTmpFiles()
removeCLICDSwapfile()
reloadSysctl() reloadSysctl()
fmt.Println() fmt.Println()
fmt.Println("CLICD 已卸载。") fmt.Println("CLICD 已卸载。")
fmt.Println("服务、二进制、配置、容器和 LXC 镜像缓存均已删除。") fmt.Println("服务、二进制、配置、容器/虚拟机、本地镜像、缓存、备份、临时文件和 CLICD 网络规则均已删除。")
} }
func destroyAllLXCContainers() { func destroyAllLXCContainers() {
@@ -730,6 +796,151 @@ func destroyAllLXCContainers() {
} }
} }
func destroyAllKVMDomains() {
if !commandExists("virsh") {
return
}
out, err := exec.Command("virsh", "list", "--all", "--name").Output()
if err != nil {
return
}
for _, line := range strings.Split(string(out), "\n") {
name := strings.TrimSpace(line)
if isCLICDKVMDomain(name) {
removeKVMDomain(name)
}
}
}
func isCLICDKVMDomain(name string) bool {
if !strings.HasPrefix(name, "vm-") || len(name) <= len("vm-") {
return false
}
for _, r := range strings.TrimPrefix(name, "vm-") {
if r < '0' || r > '9' {
return false
}
}
if dirExists("/var/lib/clicd/kvm/instances/" + name) {
return true
}
out, err := exec.Command("virsh", "dumpxml", name).Output()
return err == nil && strings.Contains(string(out), "/var/lib/clicd/kvm/")
}
func removeKVMDomain(name string) {
fmt.Printf("Removing KVM domain %s...\n", name)
runQuiet("virsh", "destroy", name)
if runCommandOK("virsh", "undefine", name, "--remove-all-storage", "--nvram") {
return
}
if runCommandOK("virsh", "undefine", name, "--nvram") {
return
}
runQuiet("virsh", "undefine", name)
}
func cleanupCLICDNetworking() {
removeCLICDNATRules()
for _, bridge := range []string{"lxcbr0", "virbr0"} {
deleteFilterRule("FORWARD", "-i", bridge, "-j", "ACCEPT")
deleteFilterRule("FORWARD", "-o", bridge, "-j", "ACCEPT")
deleteFilterRule("FORWARD", "-i", bridge, "-o", bridge, "-j", "ACCEPT")
deleteIP6TablesBridgeRules(bridge)
}
}
func removeCLICDNATRules() {
if commandExists("iptables") {
for {
out, err := exec.Command("sh", "-c", "iptables -t nat -L PREROUTING -n --line-numbers 2>/dev/null | grep 'clicd-' | awk '{print $1}' | head -n 1").Output()
line := strings.TrimSpace(string(out))
if err != nil || line == "" {
break
}
if !runCommandOK("iptables", "-t", "nat", "-D", "PREROUTING", line) {
break
}
}
deleteNATRule("POSTROUTING", "-s", "10.0.3.0/24", "-o", "eth+", "-j", "MASQUERADE")
deleteNATRule("POSTROUTING", "-s", "192.168.122.0/24", "-o", "eth+", "-j", "MASQUERADE")
}
}
func deleteNATRule(args ...string) {
fullArgs := append([]string{"-t", "nat", "-D"}, args...)
for runCommandOK("iptables", fullArgs...) {
}
}
func deleteFilterRule(args ...string) {
fullArgs := append([]string{"-D"}, args...)
for runCommandOK("iptables", fullArgs...) {
}
}
func deleteIP6TablesBridgeRules(bridge string) {
if !commandExists("ip6tables") {
return
}
for {
cmd := fmt.Sprintf("ip6tables -S FORWARD 2>/dev/null | grep -- %s | sed 's/^-A /-D /' | head -n 1", shellQuote(bridge))
out, err := exec.Command("sh", "-c", cmd).Output()
rule := strings.TrimSpace(string(out))
if err != nil || rule == "" {
return
}
if !runCommandOK("sh", "-c", "ip6tables "+rule) {
return
}
}
}
func removeCLICDHostHooks() {
runQuiet("systemctl", "stop", "clicd-kvm-ipv6.service")
runQuiet("systemctl", "disable", "clicd-kvm-ipv6.service")
runQuiet("rc-service", "clicd-kvm-ipv6", "stop")
runQuiet("rc-update", "del", "clicd-kvm-ipv6", "default")
removePath("/usr/local/sbin/clicd-kvm-ipv6-init")
removePath("/etc/systemd/system/clicd-kvm-ipv6.service")
removePath("/etc/local.d/clicd-kvm-ipv6.start")
removePath("/etc/network/if-up.d/clicd-kvm-ipv6")
}
func removeCLICDQuotaRecords() {
for _, path := range []string{"/etc/projects", "/etc/projid"} {
data, err := os.ReadFile(path)
if err != nil {
continue
}
var kept []string
for _, line := range strings.Split(string(data), "\n") {
if strings.TrimSpace(line) == "" || strings.Contains(line, "clicd-") {
continue
}
kept = append(kept, line)
}
_ = os.WriteFile(path, []byte(strings.Join(kept, "\n")+"\n"), 0644)
}
}
func removeCLICDTmpFiles() {
for _, pattern := range []string{"/tmp/clicd-*", "/tmp/clicd.*"} {
matches, _ := filepath.Glob(pattern)
for _, path := range matches {
removePath(path)
}
}
}
func removeCLICDSwapfile() {
if !fileExists("/swapfile") {
return
}
runQuiet("swapoff", "/swapfile")
removePath("/swapfile")
}
func removeLXCContainerPath(path string) { func removeLXCContainerPath(path string) {
unmountPathTree(path) unmountPathTree(path)
detachLoopDevices(path) detachLoopDevices(path)
@@ -819,6 +1030,16 @@ func removePath(path string) {
fmt.Printf("Removed %s\n", path) fmt.Printf("Removed %s\n", path)
} }
func fileExists(path string) bool {
info, err := os.Stat(path)
return err == nil && !info.IsDir()
}
func dirExists(path string) bool {
info, err := os.Stat(path)
return err == nil && info.IsDir()
}
func reloadSysctl() { func reloadSysctl() {
if commandExists("sysctl") { if commandExists("sysctl") {
runQuiet("sysctl", "--system") runQuiet("sysctl", "--system")
@@ -830,10 +1051,18 @@ func commandExists(name string) bool {
return err == nil return err == nil
} }
func runCommandOK(name string, args ...string) bool {
return exec.Command(name, args...).Run() == nil
}
func runQuiet(name string, args ...string) { func runQuiet(name string, args ...string) {
_ = exec.Command(name, args...).Run() _ = exec.Command(name, args...).Run()
} }
func shellQuote(value string) string {
return "'" + strings.ReplaceAll(value, "'", "'\"'\"'") + "'"
}
func restartWebPanelForConfigChange() { func restartWebPanelForConfigChange() {
if err := restartService("clicd"); err != nil { if err := restartService("clicd"); err != nil {
fmt.Printf("Web 面板重载跳过: %v\n", err) fmt.Printf("Web 面板重载跳过: %v\n", err)
+54
View File
@@ -0,0 +1,54 @@
package cli
import (
"strings"
"testing"
)
func TestSafeReleaseBackupComponent(t *testing.T) {
tests := map[string]string{
"v1.2.3": "1.2.3",
" release/candidate ": "release_candidate",
"../../etc/passwd": "etc_passwd",
"": "unknown",
}
for input, want := range tests {
if got := safeReleaseBackupComponent(input); got != want {
t.Fatalf("safeReleaseBackupComponent(%q) = %q, want %q", input, got, want)
}
}
}
func TestCopyFileToBackupRejectsUnsafeFileName(t *testing.T) {
unsafeNames := []string{
"../clicd",
"..\\clicd",
"subdir/clicd",
"",
}
for _, name := range unsafeNames {
if _, err := copyFileToBackup("missing-source", name, 0755); err == nil || !strings.Contains(err.Error(), "unsafe backup file name") {
t.Fatalf("copyFileToBackup(%q) error = %v, want unsafe backup file name", name, err)
}
}
}
func TestFormatSSHAccessDoesNotExposePassword(t *testing.T) {
out := formatSSHAccess(2222)
if strings.Contains(out, "/") {
t.Fatalf("formatSSHAccess output contains credential separator: %q", out)
}
if strings.Contains(strings.ToLower(out), "password123") {
t.Fatalf("formatSSHAccess output exposed password: %q", out)
}
if !strings.Contains(out, "2222 -> 22") {
t.Fatalf("formatSSHAccess output = %q, want SSH port mapping", out)
}
}
func TestFormatSSHAccessHandlesMissingPort(t *testing.T) {
out := formatSSHAccess(0)
if !strings.Contains(out, "端口未分配") {
t.Fatalf("formatSSHAccess output = %q, want missing port message", out)
}
}
+214 -103
View File
@@ -3,7 +3,6 @@ package config
import ( import (
"crypto/rand" "crypto/rand"
"encoding/hex" "encoding/hex"
"encoding/json"
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
@@ -58,14 +57,11 @@ type AuditLog struct {
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
} }
// OversellConfig controls host-level overselling behavior type VMReadinessCheck struct {
type OversellConfig struct { Key string `json:"key"`
CPUOvercommit int `json:"cpu_overcommit"` // multiplier, e.g. 4 means 4x oversell Label string `json:"label"`
RAMOvercommit int `json:"ram_overcommit"` // multiplier OK bool `json:"ok"`
DiskOvercommit int `json:"disk_overcommit"` // multiplier Detail string `json:"detail,omitempty"`
KSMEnabled bool `json:"ksm_enabled"` // kernel same-page merging
Swappiness int `json:"swappiness"` // 0-100, lower = less swap
SubUserSnapshotLimit int `json:"sub_user_snapshot_limit"` // legacy default for migrating old containers
} }
// Container represents an LXC container configuration // Container represents an LXC container configuration
@@ -73,7 +69,11 @@ type Container struct {
ID int `json:"id"` ID int `json:"id"`
UUID string `json:"uuid"` UUID string `json:"uuid"`
Name string `json:"name"` Name string `json:"name"`
Virtualization string `json:"virtualization,omitempty"`
LXCName string `json:"lxc_name,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"` Template string `json:"template"`
VCPU float64 `json:"vcpu"` VCPU float64 `json:"vcpu"`
RAMMB int `json:"ram_mb"` RAMMB int `json:"ram_mb"`
@@ -107,6 +107,31 @@ type Container struct {
SnapshotScheduleLastRun string `json:"snapshot_schedule_last_run"` SnapshotScheduleLastRun string `json:"snapshot_schedule_last_run"`
SnapshotScheduleNextRun string `json:"snapshot_schedule_next_run"` SnapshotScheduleNextRun string `json:"snapshot_schedule_next_run"`
SnapshotScheduleCreatedBy string `json:"snapshot_schedule_created_by"` 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 (
VirtualizationLXC = "lxc"
VirtualizationKVM = "kvm"
)
func NormalizeVirtualization(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case VirtualizationKVM:
return VirtualizationKVM
default:
return VirtualizationLXC
}
}
func (c *Container) Runtime() string {
return NormalizeVirtualization(c.Virtualization)
}
func (c *Container) IsKVM() bool {
return c.Runtime() == VirtualizationKVM
} }
// LxcName returns the internal LXC container name (ct-{id}) // LxcName returns the internal LXC container name (ct-{id})
@@ -117,6 +142,14 @@ func (c *Container) LxcName() string {
return fmt.Sprintf("ct-%d", c.ID) return fmt.Sprintf("ct-%d", c.ID)
} }
// VirshName returns the internal libvirt domain name for KVM instances.
func (c *Container) VirshName() string {
if c.KVMName != "" {
return c.KVMName
}
return fmt.Sprintf("vm-%d", c.ID)
}
// SubUser represents a sub-user with access to specific containers // SubUser represents a sub-user with access to specific containers
type ApiKeyConfig struct { type ApiKeyConfig struct {
ID string `json:"id"` ID string `json:"id"`
@@ -167,24 +200,24 @@ type Snapshot struct {
// ClicdConfig is the main configuration structure // ClicdConfig is the main configuration structure
type ClicdConfig struct { type ClicdConfig struct {
AdminUser string `json:"admin_user"` AdminUser string `json:"admin_user"`
AdminPassHash string `json:"admin_pass_hash"` AdminPassHash string `json:"admin_pass_hash"`
JWTSecret string `json:"jwt_secret"` JWTSecret string `json:"jwt_secret"`
Port int `json:"port"` Port int `json:"port"`
DataDir string `json:"data_dir"` DataDir string `json:"data_dir"`
Containers []Container `json:"containers"` Containers []Container `json:"containers"`
NextContainerID int `json:"next_container_id"` NextContainerID int `json:"next_container_id"`
NextVNCPort int `json:"next_vnc_port"` NextVNCPort int `json:"next_vnc_port"`
NextSSHPort int `json:"next_ssh_port"` NextSSHPort int `json:"next_ssh_port"`
SetupComplete bool `json:"setup_complete"` SetupComplete bool `json:"setup_complete"`
Oversell OversellConfig `json:"oversell"` SubUsers []SubUser `json:"sub_users"`
SubUsers []SubUser `json:"sub_users"` ApiKeys []ApiKeyConfig `json:"api_keys"`
ApiKeys []ApiKeyConfig `json:"api_keys"` AuditLogs []AuditLog `json:"audit_logs"`
AuditLogs []AuditLog `json:"audit_logs"` Tasks []SavedTask `json:"tasks"`
Tasks []SavedTask `json:"tasks"` LoginLogs []SavedLoginLog `json:"login_logs"`
LoginLogs []SavedLoginLog `json:"login_logs"` EnabledImages []string `json:"enabled_images"`
EnabledImages []string `json:"enabled_images"` Snapshots []Snapshot `json:"snapshots"`
Snapshots []Snapshot `json:"snapshots"` SecurityAutoShutdown bool `json:"security_auto_shutdown"`
} }
var configPath string var configPath string
@@ -247,77 +280,91 @@ func InitConfig() (*ClicdConfig, error) {
dataDir := getDataDir() dataDir := getDataDir()
if err := os.MkdirAll(filepath.Dir(cfgPath), 0700); err != nil { if err := os.MkdirAll(filepath.Dir(cfgPath), 0700); err != nil {
return nil, fmt.Errorf("failed to create config directory: %v", err) return nil, fmt.Errorf("failed to create data directory: %v", err)
} }
if err := os.MkdirAll(dataDir, 0700); err != nil { if err := os.MkdirAll(dataDir, 0700); err != nil {
return nil, fmt.Errorf("failed to create data directory: %v", err) return nil, fmt.Errorf("failed to create data directory: %v", err)
} }
if err := openConfigDB(); err != nil {
return nil, err
}
if _, err := os.Stat(cfgPath); os.IsNotExist(err) { cfg, ok, err := loadConfigFromDB()
// First run: generate new config if err != nil {
adminUser := "admin" return nil, err
adminPass := generateRandomString(16) }
jwtSecret := generateRandomString(32) if ok {
hash, err := bcrypt.GenerateFromPassword([]byte(adminPass), bcrypt.DefaultCost) AppConfig = cfg
if err != nil { normalizeConfigDefaults(dataDir)
return nil, fmt.Errorf("failed to hash password: %v", err) if migrateLoadedConfig() {
if err := SaveConfig(); err != nil {
return nil, err
}
} }
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{},
Oversell: OversellConfig{
CPUOvercommit: 4,
RAMOvercommit: 1,
DiskOvercommit: 2,
KSMEnabled: true,
Swappiness: 10,
SubUserSnapshotLimit: 3,
},
Snapshots: []Snapshot{},
}
if err := SaveConfig(); err != nil {
return nil, err
}
fmt.Println("\n========================================")
fmt.Println(" CLICD - LXC Container Manager")
fmt.Println("========================================")
fmt.Printf(" Username: %s\n", adminUser)
fmt.Printf(" Password: %s\n", adminPass)
fmt.Println("========================================")
fmt.Println(" Please save these credentials!")
fmt.Println(" Web Interface: http://0.0.0.0:8999")
fmt.Println("========================================")
fmt.Println()
return AppConfig, nil return AppConfig, nil
} }
// Load existing config legacy, ok, err := loadLegacyJSONConfig(cfgPath)
data, err := os.ReadFile(cfgPath)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to read config: %v", err) return nil, err
}
if ok {
AppConfig = legacy
normalizeConfigDefaults(dataDir)
if migrateLoadedConfig() {
// Save below persists normalized legacy data into SQLite.
}
if err := SaveConfig(); err != nil {
return nil, err
}
return AppConfig, nil
} }
AppConfig = &ClicdConfig{} adminUser := "admin"
if err := json.Unmarshal(data, AppConfig); err != nil { adminPass := generateRandomString(16)
return nil, fmt.Errorf("failed to parse config: %v", err) jwtSecret := generateRandomString(32)
hash, err := bcrypt.GenerateFromPassword([]byte(adminPass), bcrypt.DefaultCost)
if err != nil {
return nil, fmt.Errorf("failed to hash password: %v", err)
} }
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{},
}
if err := SaveConfig(); err != nil {
return nil, err
}
fmt.Println("\n========================================")
fmt.Println(" CLICD - LXC Container Manager")
fmt.Println("========================================")
fmt.Printf(" Username: %s\n", adminUser)
fmt.Printf(" Password: %s\n", adminPass)
fmt.Println("========================================")
fmt.Println(" Please save these credentials!")
fmt.Println(" Web Interface: http://0.0.0.0:8999")
fmt.Println("========================================")
fmt.Println()
return AppConfig, nil
}
func normalizeConfigDefaults(dataDir string) {
if AppConfig.Port == 0 { if AppConfig.Port == 0 {
AppConfig.Port = 8999 AppConfig.Port = 8999
} }
@@ -339,10 +386,31 @@ func InitConfig() (*ClicdConfig, error) {
if AppConfig.Snapshots == nil { if AppConfig.Snapshots == nil {
AppConfig.Snapshots = make([]Snapshot, 0) AppConfig.Snapshots = make([]Snapshot, 0)
} }
if AppConfig.Oversell.SubUserSnapshotLimit <= 0 { if AppConfig.SubUsers == nil {
AppConfig.Oversell.SubUserSnapshotLimit = 3 AppConfig.SubUsers = make([]SubUser, 0)
} }
if AppConfig.ApiKeys == nil {
AppConfig.ApiKeys = make([]ApiKeyConfig, 0)
}
if AppConfig.AuditLogs == nil {
AppConfig.AuditLogs = make([]AuditLog, 0)
}
if AppConfig.Tasks == nil {
AppConfig.Tasks = make([]SavedTask, 0)
}
if AppConfig.LoginLogs == nil {
AppConfig.LoginLogs = make([]SavedLoginLog, 0)
}
if AppConfig.EnabledImages == nil {
AppConfig.EnabledImages = make([]string, 0)
}
}
func migrateLoadedConfig() bool {
changed := ensureContainerUUIDs() changed := ensureContainerUUIDs()
if ensureContainerVirtualization() {
changed = true
}
if ensureContainerPortMappingLimits() { if ensureContainerPortMappingLimits() {
changed = true changed = true
} }
@@ -358,13 +426,19 @@ func InitConfig() (*ClicdConfig, error) {
if removeLegacyVNCMappings() { if removeLegacyVNCMappings() {
changed = true changed = true
} }
if changed { return changed
if err := SaveConfig(); err != nil { }
return nil, err
func ensureContainerVirtualization() bool {
changed := false
for i := range AppConfig.Containers {
next := NormalizeVirtualization(AppConfig.Containers[i].Virtualization)
if AppConfig.Containers[i].Virtualization != next {
AppConfig.Containers[i].Virtualization = next
changed = true
} }
} }
return changed
return AppConfig, nil
} }
func ensureContainerSnapshotScheduleDefaults() bool { func ensureContainerSnapshotScheduleDefaults() bool {
@@ -419,13 +493,9 @@ func ensureContainerPortMappingLimits() bool {
func ensureContainerSnapshotLimits() bool { func ensureContainerSnapshotLimits() bool {
changed := false changed := false
legacyLimit := AppConfig.Oversell.SubUserSnapshotLimit
if legacyLimit <= 0 {
legacyLimit = DefaultSnapshotLimit
}
for i := range AppConfig.Containers { for i := range AppConfig.Containers {
if AppConfig.Containers[i].SnapshotLimit <= 0 { if AppConfig.Containers[i].SnapshotLimit <= 0 {
AppConfig.Containers[i].SnapshotLimit = legacyLimit AppConfig.Containers[i].SnapshotLimit = DefaultSnapshotLimit
changed = true changed = true
} }
} }
@@ -507,11 +577,7 @@ func removeLegacyVNCMappings() bool {
// SaveConfig saves configuration to disk // SaveConfig saves configuration to disk
func SaveConfig() error { func SaveConfig() error {
data, err := json.MarshalIndent(AppConfig, "", " ") return saveConfigToDB()
if err != nil {
return fmt.Errorf("failed to marshal config: %v", err)
}
return os.WriteFile(getConfigPath(), data, 0600)
} }
// AddContainer adds a container to the config // AddContainer adds a container to the config
@@ -519,6 +585,7 @@ func AddContainer(c Container) {
if c.UUID == "" { if c.UUID == "" {
c.UUID = NewContainerUUID() c.UUID = NewContainerUUID()
} }
c.Virtualization = NormalizeVirtualization(c.Virtualization)
AppConfig.Containers = append(AppConfig.Containers, c) AppConfig.Containers = append(AppConfig.Containers, c)
SaveConfig() SaveConfig()
} }
@@ -686,20 +753,51 @@ func UpdateContainerStatus(id int, status string) {
} }
} }
func SetContainerPolicyBlock(id int, blocked bool, reason string) {
c := FindContainer(id)
if c == nil {
return
}
c.PolicyBlocked = blocked
if blocked {
c.PolicyBlockedReason = reason
c.PolicyBlockedAt = time.Now().Format("2006-01-02 15:04:05")
} else {
c.PolicyBlockedReason = ""
c.PolicyBlockedAt = ""
}
SaveConfig()
}
// UpdateVNC refreshes all container statuses // UpdateVNC refreshes all container statuses
func UpdateVNC(containers []Container) { func UpdateVNC(containers []Container) {
AppConfig.Containers = containers AppConfig.Containers = containers
SaveConfig() SaveConfig()
} }
// AllocateSSHPort allocates a new SSH port // AllocateSSHPort allocates a new SSH port, skipping ports already used by any container
func AllocateSSHPort() int { func AllocateSSHPort() int {
used := collectAllHostPorts()
port := AppConfig.NextSSHPort port := AppConfig.NextSSHPort
AppConfig.NextSSHPort++ for used[port] {
port++
}
AppConfig.NextSSHPort = port + 1
SaveConfig() SaveConfig()
return port return port
} }
// collectAllHostPorts collects all host ports used by any container (LXC + KVM)
func collectAllHostPorts() map[int]bool {
used := map[int]bool{}
for _, c := range AppConfig.Containers {
for _, pm := range c.PortMappings {
used[pm.HostPort] = true
}
}
return used
}
// IsValidContainerName checks if container name is valid (no duplicate check needed, ID is primary key) // IsValidContainerName checks if container name is valid (no duplicate check needed, ID is primary key)
func IsValidContainerName(name string) bool { func IsValidContainerName(name string) bool {
return IsValidContainerNameSyntax(name) return IsValidContainerNameSyntax(name)
@@ -792,6 +890,19 @@ func CleanStaleContainers() {
valid := make([]Container, 0) valid := make([]Container, 0)
changed := false changed := false
for _, c := range AppConfig.Containers { for _, c := range AppConfig.Containers {
if c.IsKVM() {
if c.DiskImage == "" {
valid = append(valid, c)
continue
}
if _, err := os.Stat(c.DiskImage); os.IsNotExist(err) {
fmt.Printf("Cleaning stale KVM config: %s (disk image not found)\n", c.VirshName())
changed = true
continue
}
valid = append(valid, c)
continue
}
lxcDir := "/var/lib/lxc/" + c.LxcName() lxcDir := "/var/lib/lxc/" + c.LxcName()
if _, err := os.Stat(lxcDir); os.IsNotExist(err) { if _, err := os.Stat(lxcDir); os.IsNotExist(err) {
fmt.Printf("Cleaning stale container config: %s (LXC dir not found)\n", c.LxcName()) fmt.Printf("Cleaning stale container config: %s (LXC dir not found)\n", c.LxcName())
+865
View File
@@ -0,0 +1,865 @@
package config
import (
"database/sql"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
_ "modernc.org/sqlite"
)
var (
dbMu sync.Mutex
db *sql.DB
)
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"`
}
func parseSavedTaskConfig(raw string) savedTaskConfig {
if raw == "" {
return savedTaskConfig{}
}
var cfg savedTaskConfig
_ = json.Unmarshal([]byte(raw), &cfg)
return cfg
}
func encodeSavedTaskConfig(cfg savedTaskConfig) string {
data, err := json.Marshal(cfg)
if err != nil {
return ""
}
return string(data)
}
func getDBPath() string {
cfgPath := getConfigPath()
ext := filepath.Ext(cfgPath)
if ext == "" {
return cfgPath + ".db"
}
return strings.TrimSuffix(cfgPath, ext) + ".db"
}
func openConfigDB() error {
if db != nil {
return nil
}
dbPath := getDBPath()
if err := os.MkdirAll(filepath.Dir(dbPath), 0700); err != nil {
return fmt.Errorf("failed to create database directory: %v", err)
}
next, err := sql.Open("sqlite", dbPath)
if err != nil {
return fmt.Errorf("failed to open sqlite database: %v", err)
}
next.SetMaxOpenConns(1)
next.SetMaxIdleConns(1)
for _, stmt := range []string{
"PRAGMA journal_mode=WAL",
"PRAGMA synchronous=NORMAL",
"PRAGMA busy_timeout=5000",
"PRAGMA foreign_keys=ON",
} {
if _, err := next.Exec(stmt); err != nil {
_ = next.Close()
return fmt.Errorf("failed to initialize sqlite pragma: %v", err)
}
}
db = next
return ensureSchema()
}
func ensureSchema() error {
stmts := []string{
`CREATE TABLE IF NOT EXISTS app_meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
)`,
`CREATE TABLE IF NOT EXISTS containers (
id INTEGER PRIMARY KEY,
uuid TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
virtualization TEXT,
lxc_name TEXT,
kvm_name TEXT,
disk_image TEXT,
mac_address TEXT,
template TEXT,
vcpu REAL,
ram_mb INTEGER,
disk_gb INTEGER,
network_bw_mbps INTEGER,
monthly_traffic_gb INTEGER,
traffic_mode TEXT,
traffic_in_gb INTEGER,
traffic_out_gb INTEGER,
traffic_used_rx INTEGER,
traffic_used_tx INTEGER,
traffic_reset_date TEXT,
io_speed_mbps INTEGER,
status TEXT,
ip TEXT,
ipv6 TEXT,
ipv6_prefix_len INTEGER,
ipv6_interface TEXT,
vnc_port INTEGER,
ssh_port INTEGER,
ssh_password TEXT,
ssh_host_key TEXT,
port_mapping_limit INTEGER,
snapshot_limit INTEGER,
created_at TEXT,
expires_at TEXT,
snapshot_schedule_enabled INTEGER,
snapshot_schedule_interval_hours INTEGER,
snapshot_schedule_time TEXT,
snapshot_schedule_last_run TEXT,
snapshot_schedule_next_run TEXT,
snapshot_schedule_created_by TEXT,
policy_blocked INTEGER,
policy_blocked_reason TEXT,
policy_blocked_at TEXT
)`,
`CREATE TABLE IF NOT EXISTS port_mappings (
container_id INTEGER NOT NULL,
position INTEGER NOT NULL,
container_port INTEGER NOT NULL,
host_port INTEGER NOT NULL,
protocol TEXT,
description TEXT,
PRIMARY KEY (container_id, position)
)`,
`CREATE TABLE IF NOT EXISTS sub_users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL,
password TEXT,
pass_hash TEXT,
access_code TEXT,
created_at TEXT,
token_version INTEGER
)`,
`CREATE TABLE IF NOT EXISTS sub_user_container_names (
sub_user_id TEXT NOT NULL,
position INTEGER NOT NULL,
container_name TEXT NOT NULL,
PRIMARY KEY (sub_user_id, position)
)`,
`CREATE TABLE IF NOT EXISTS sub_user_container_uuids (
sub_user_id TEXT NOT NULL,
position INTEGER NOT NULL,
container_uuid TEXT NOT NULL,
PRIMARY KEY (sub_user_id, position)
)`,
`CREATE TABLE IF NOT EXISTS api_keys (
id TEXT PRIMARY KEY,
name TEXT,
key_hash TEXT,
prefix TEXT,
ip_whitelist TEXT,
created_at TEXT,
last_used TEXT
)`,
`CREATE TABLE IF NOT EXISTS audit_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
time TEXT,
action TEXT,
target TEXT,
detail TEXT,
user TEXT,
ip TEXT,
user_agent TEXT,
success_set INTEGER,
success INTEGER,
error TEXT
)`,
`CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY,
type TEXT,
container_id INTEGER,
container_name TEXT,
status TEXT,
error TEXT,
created_at TEXT,
template_id TEXT,
user TEXT,
cfg_name TEXT,
cfg_virtualization TEXT,
cfg_template_id TEXT,
cfg_vcpu REAL,
cfg_cpu_percent INTEGER,
cfg_ram_mb INTEGER,
cfg_disk_gb INTEGER,
cfg_network_bw_mbps INTEGER,
cfg_monthly_traffic_gb INTEGER,
cfg_traffic_mode TEXT,
cfg_traffic_in_gb INTEGER,
cfg_traffic_out_gb INTEGER,
cfg_io_speed_mbps INTEGER,
cfg_port_mapping_count INTEGER,
cfg_snapshot_limit INTEGER,
cfg_assign_ipv6 INTEGER,
cfg_expires_at TEXT
)`,
`CREATE TABLE IF NOT EXISTS task_extra_ports (
task_id TEXT NOT NULL,
position INTEGER NOT NULL,
port INTEGER NOT NULL,
PRIMARY KEY (task_id, position)
)`,
`CREATE TABLE IF NOT EXISTS login_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
time TEXT,
username TEXT,
ip TEXT,
user_agent TEXT,
success INTEGER
)`,
`CREATE TABLE IF NOT EXISTS enabled_images (
position INTEGER PRIMARY KEY,
image_id TEXT NOT NULL
)`,
`CREATE TABLE IF NOT EXISTS snapshots (
id TEXT PRIMARY KEY,
container_id INTEGER,
container_name TEXT,
lxc_name TEXT,
created_at TEXT,
created_by TEXT,
scheduled INTEGER,
path TEXT,
size_bytes INTEGER
)`,
}
for _, stmt := range stmts {
if _, err := db.Exec(stmt); err != nil {
return fmt.Errorf("failed to create sqlite schema: %v", err)
}
}
return nil
}
func loadConfigFromDB() (*ClicdConfig, bool, error) {
meta := map[string]string{}
rows, err := db.Query("SELECT key, value FROM app_meta")
if err != nil {
return nil, false, err
}
defer rows.Close()
for rows.Next() {
var k, v string
if err := rows.Scan(&k, &v); err != nil {
return nil, false, err
}
meta[k] = v
}
if err := rows.Err(); err != nil {
return nil, false, err
}
if meta["admin_user"] == "" {
return nil, false, nil
}
cfg := &ClicdConfig{
AdminUser: meta["admin_user"],
AdminPassHash: meta["admin_pass_hash"],
JWTSecret: meta["jwt_secret"],
Port: atoi(meta["port"]),
DataDir: meta["data_dir"],
NextContainerID: atoi(meta["next_container_id"]),
NextVNCPort: atoi(meta["next_vnc_port"]),
NextSSHPort: atoi(meta["next_ssh_port"]),
SetupComplete: atob(meta["setup_complete"]),
SecurityAutoShutdown: atob(meta["security_auto_shutdown"]),
}
if cfg.Containers, err = loadContainers(); err != nil {
return nil, false, err
}
if cfg.SubUsers, err = loadSubUsers(); err != nil {
return nil, false, err
}
if cfg.ApiKeys, err = loadAPIKeys(); err != nil {
return nil, false, err
}
if cfg.AuditLogs, err = loadAuditLogs(); err != nil {
return nil, false, err
}
if cfg.Tasks, err = loadTasks(); err != nil {
return nil, false, err
}
if cfg.LoginLogs, err = loadLoginLogs(); err != nil {
return nil, false, err
}
if cfg.EnabledImages, err = loadEnabledImages(); err != nil {
return nil, false, err
}
if cfg.Snapshots, err = loadSnapshots(); err != nil {
return nil, false, err
}
return cfg, true, nil
}
func saveConfigToDB() error {
if db == nil {
return fmt.Errorf("sqlite database is not initialized")
}
dbMu.Lock()
defer dbMu.Unlock()
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
for _, table := range []string{
"port_mappings",
"sub_user_container_names",
"sub_user_container_uuids",
"containers",
"sub_users",
"api_keys",
"audit_logs",
"task_extra_ports",
"tasks",
"login_logs",
"enabled_images",
"snapshots",
"app_meta",
} {
if _, err := tx.Exec("DELETE FROM " + table); err != nil {
return err
}
}
if err := saveMeta(tx); err != nil {
return err
}
if err := saveContainers(tx); err != nil {
return err
}
if err := saveSubUsers(tx); err != nil {
return err
}
if err := saveAPIKeys(tx); err != nil {
return err
}
if err := saveAuditLogs(tx); err != nil {
return err
}
if err := saveTasksDB(tx); err != nil {
return err
}
if err := saveLoginLogs(tx); err != nil {
return err
}
if err := saveEnabledImages(tx); err != nil {
return err
}
if err := saveSnapshots(tx); err != nil {
return err
}
return tx.Commit()
}
func saveMeta(tx *sql.Tx) error {
values := map[string]string{
"admin_user": AppConfig.AdminUser,
"admin_pass_hash": AppConfig.AdminPassHash,
"jwt_secret": AppConfig.JWTSecret,
"port": strconv.Itoa(AppConfig.Port),
"data_dir": AppConfig.DataDir,
"next_container_id": strconv.Itoa(AppConfig.NextContainerID),
"next_vnc_port": strconv.Itoa(AppConfig.NextVNCPort),
"next_ssh_port": strconv.Itoa(AppConfig.NextSSHPort),
"setup_complete": btoa(AppConfig.SetupComplete),
"security_auto_shutdown": btoa(AppConfig.SecurityAutoShutdown),
"schema_version": "1",
"updated_at": time.Now().Format("2006-01-02 15:04:05"),
}
for k, v := range values {
if _, err := tx.Exec("INSERT INTO app_meta(key, value) VALUES (?, ?)", k, v); err != nil {
return err
}
}
return nil
}
func saveContainers(tx *sql.Tx) error {
for _, c := range AppConfig.Containers {
if _, err := tx.Exec(`INSERT INTO containers (
id, uuid, name, virtualization, lxc_name, kvm_name, disk_image, mac_address, template,
vcpu, ram_mb, disk_gb, network_bw_mbps, monthly_traffic_gb, traffic_mode, traffic_in_gb,
traffic_out_gb, traffic_used_rx, traffic_used_tx, traffic_reset_date, io_speed_mbps,
status, ip, ipv6, ipv6_prefix_len, ipv6_interface, vnc_port, ssh_port, ssh_password,
ssh_host_key, port_mapping_limit, snapshot_limit, created_at, expires_at,
snapshot_schedule_enabled, snapshot_schedule_interval_hours, snapshot_schedule_time,
snapshot_schedule_last_run, snapshot_schedule_next_run, snapshot_schedule_created_by,
policy_blocked, policy_blocked_reason, policy_blocked_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
c.ID, c.UUID, c.Name, c.Virtualization, c.LXCName, c.KVMName, c.DiskImage, c.MACAddress, c.Template,
c.VCPU, c.RAMMB, c.DiskGB, c.NetworkBWMbps, c.MonthlyTrafficGB, c.TrafficMode, c.TrafficInGB,
c.TrafficOutGB, c.TrafficUsedRX, c.TrafficUsedTX, c.TrafficResetDate, c.IOSpeedMBps,
c.Status, c.IP, c.IPv6, c.IPv6PrefixLen, c.IPv6Interface, c.VNCPort, c.SSHPort, c.SSHPassword,
c.SSHHostKey, c.PortMappingLimit, c.SnapshotLimit, c.CreatedAt, c.ExpiresAt,
boolInt(c.SnapshotScheduleEnabled), c.SnapshotScheduleIntervalHours, c.SnapshotScheduleTime,
c.SnapshotScheduleLastRun, c.SnapshotScheduleNextRun, c.SnapshotScheduleCreatedBy,
boolInt(c.PolicyBlocked), c.PolicyBlockedReason, c.PolicyBlockedAt,
); err != nil {
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 {
return err
}
}
}
return nil
}
func saveSubUsers(tx *sql.Tx) error {
for _, su := range AppConfig.SubUsers {
if _, err := tx.Exec(`INSERT INTO sub_users(id, username, password, pass_hash, access_code, created_at, token_version)
VALUES (?, ?, ?, ?, ?, ?, ?)`, su.ID, su.Username, su.Password, su.PassHash, su.AccessCode, su.CreatedAt, su.TokenVersion); err != nil {
return err
}
for i, name := range su.ContainerNames {
if _, err := tx.Exec(`INSERT INTO sub_user_container_names(sub_user_id, position, container_name) VALUES (?, ?, ?)`, su.ID, i, name); err != nil {
return err
}
}
for i, uuid := range su.ContainerUUIDs {
if _, err := tx.Exec(`INSERT INTO sub_user_container_uuids(sub_user_id, position, container_uuid) VALUES (?, ?, ?)`, su.ID, i, uuid); err != nil {
return err
}
}
}
return nil
}
func saveAPIKeys(tx *sql.Tx) error {
for _, k := range AppConfig.ApiKeys {
if _, err := tx.Exec(`INSERT INTO api_keys(id, name, key_hash, prefix, ip_whitelist, created_at, last_used)
VALUES (?, ?, ?, ?, ?, ?, ?)`, k.ID, k.Name, k.KeyHash, k.Prefix, k.IPWhitelist, k.CreatedAt, k.LastUsed); err != nil {
return err
}
}
return nil
}
func saveAuditLogs(tx *sql.Tx) error {
for _, log := range AppConfig.AuditLogs {
successSet := 0
success := 0
if log.Success != nil {
successSet = 1
if *log.Success {
success = 1
}
}
if _, err := tx.Exec(`INSERT INTO audit_logs(time, action, target, detail, user, ip, user_agent, success_set, success, error)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, log.Time, log.Action, log.Target, log.Detail, log.User, log.IP, log.UserAgent, successSet, success, log.Error); err != nil {
return err
}
}
return nil
}
func saveTasksDB(tx *sql.Tx) error {
for _, task := range AppConfig.Tasks {
cfg := parseSavedTaskConfig(task.Config)
if _, err := tx.Exec(`INSERT INTO tasks(
id, type, container_id, container_name, status, error, created_at, template_id, user,
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
task.ID, task.Type, task.ContainerID, task.ContainerName, task.Status, task.Error, task.CreatedAt, task.TemplateID, task.User,
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,
); err != nil {
return err
}
for i, port := range cfg.ExtraPorts {
if _, err := tx.Exec(`INSERT INTO task_extra_ports(task_id, position, port) VALUES (?, ?, ?)`, task.ID, i, port); err != nil {
return err
}
}
}
return nil
}
func saveLoginLogs(tx *sql.Tx) error {
for _, log := range AppConfig.LoginLogs {
if _, err := tx.Exec(`INSERT INTO login_logs(time, username, ip, user_agent, success) VALUES (?, ?, ?, ?, ?)`,
log.Time, log.Username, log.IP, log.UserAgent, boolInt(log.Success)); err != nil {
return err
}
}
return nil
}
func saveEnabledImages(tx *sql.Tx) error {
for i, id := range AppConfig.EnabledImages {
if _, err := tx.Exec(`INSERT INTO enabled_images(position, image_id) VALUES (?, ?)`, i, id); err != nil {
return err
}
}
return nil
}
func saveSnapshots(tx *sql.Tx) error {
for _, snapshot := range AppConfig.Snapshots {
if _, err := tx.Exec(`INSERT INTO snapshots(id, container_id, container_name, lxc_name, created_at, created_by, scheduled, path, size_bytes)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, snapshot.ID, snapshot.ContainerID, snapshot.ContainerName, snapshot.LXCName, snapshot.CreatedAt, snapshot.CreatedBy, boolInt(snapshot.Scheduled), snapshot.Path, snapshot.SizeBytes); err != nil {
return err
}
}
return nil
}
func loadContainers() ([]Container, error) {
rows, err := db.Query(`SELECT
id, uuid, name, virtualization, lxc_name, kvm_name, disk_image, mac_address, template,
vcpu, ram_mb, disk_gb, network_bw_mbps, monthly_traffic_gb, traffic_mode, traffic_in_gb,
traffic_out_gb, traffic_used_rx, traffic_used_tx, traffic_reset_date, io_speed_mbps,
status, ip, ipv6, ipv6_prefix_len, ipv6_interface, vnc_port, ssh_port, ssh_password,
ssh_host_key, port_mapping_limit, snapshot_limit, created_at, expires_at,
snapshot_schedule_enabled, snapshot_schedule_interval_hours, snapshot_schedule_time,
snapshot_schedule_last_run, snapshot_schedule_next_run, snapshot_schedule_created_by,
policy_blocked, policy_blocked_reason, policy_blocked_at
FROM containers ORDER BY id`)
if err != nil {
return nil, err
}
defer rows.Close()
result := []Container{}
for rows.Next() {
var c Container
var scheduleEnabled, policyBlocked int
if err := rows.Scan(
&c.ID, &c.UUID, &c.Name, &c.Virtualization, &c.LXCName, &c.KVMName, &c.DiskImage, &c.MACAddress, &c.Template,
&c.VCPU, &c.RAMMB, &c.DiskGB, &c.NetworkBWMbps, &c.MonthlyTrafficGB, &c.TrafficMode, &c.TrafficInGB,
&c.TrafficOutGB, &c.TrafficUsedRX, &c.TrafficUsedTX, &c.TrafficResetDate, &c.IOSpeedMBps,
&c.Status, &c.IP, &c.IPv6, &c.IPv6PrefixLen, &c.IPv6Interface, &c.VNCPort, &c.SSHPort, &c.SSHPassword,
&c.SSHHostKey, &c.PortMappingLimit, &c.SnapshotLimit, &c.CreatedAt, &c.ExpiresAt,
&scheduleEnabled, &c.SnapshotScheduleIntervalHours, &c.SnapshotScheduleTime,
&c.SnapshotScheduleLastRun, &c.SnapshotScheduleNextRun, &c.SnapshotScheduleCreatedBy,
&policyBlocked, &c.PolicyBlockedReason, &c.PolicyBlockedAt,
); err != nil {
return nil, err
}
c.SnapshotScheduleEnabled = scheduleEnabled != 0
c.PolicyBlocked = policyBlocked != 0
result = append(result, c)
}
if err := rows.Err(); err != nil {
return nil, err
}
if err := rows.Close(); err != nil {
return nil, err
}
for i := range result {
result[i].PortMappings, err = loadPortMappings(result[i].ID)
if err != nil {
return nil, err
}
}
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)
if err != nil {
return nil, err
}
defer rows.Close()
result := []PortMapping{}
for rows.Next() {
var pm PortMapping
if err := rows.Scan(&pm.ContainerPort, &pm.HostPort, &pm.Protocol, &pm.Description); err != nil {
return nil, err
}
result = append(result, pm)
}
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 {
return nil, err
}
defer rows.Close()
result := []SubUser{}
for rows.Next() {
var su SubUser
if err := rows.Scan(&su.ID, &su.Username, &su.Password, &su.PassHash, &su.AccessCode, &su.CreatedAt, &su.TokenVersion); err != nil {
return nil, err
}
result = append(result, su)
}
if err := rows.Err(); err != nil {
return nil, err
}
if err := rows.Close(); err != nil {
return nil, err
}
for i := range result {
result[i].ContainerNames, err = loadStringList("sub_user_container_names", "container_name", "sub_user_id", result[i].ID)
if err != nil {
return nil, err
}
result[i].ContainerUUIDs, err = loadStringList("sub_user_container_uuids", "container_uuid", "sub_user_id", result[i].ID)
if err != nil {
return nil, err
}
}
return result, nil
}
func loadStringList(table, valueColumn, keyColumn, key string) ([]string, error) {
rows, err := db.Query(fmt.Sprintf(`SELECT %s FROM %s WHERE %s = ? ORDER BY position`, valueColumn, table, keyColumn), key)
if err != nil {
return nil, err
}
defer rows.Close()
values := []string{}
for rows.Next() {
var value string
if err := rows.Scan(&value); err != nil {
return nil, err
}
values = append(values, value)
}
return values, rows.Err()
}
func loadAPIKeys() ([]ApiKeyConfig, error) {
rows, err := db.Query(`SELECT id, name, key_hash, prefix, ip_whitelist, created_at, last_used FROM api_keys ORDER BY created_at, id`)
if err != nil {
return nil, err
}
defer rows.Close()
result := []ApiKeyConfig{}
for rows.Next() {
var k ApiKeyConfig
if err := rows.Scan(&k.ID, &k.Name, &k.KeyHash, &k.Prefix, &k.IPWhitelist, &k.CreatedAt, &k.LastUsed); err != nil {
return nil, err
}
result = append(result, k)
}
return result, rows.Err()
}
func loadAuditLogs() ([]AuditLog, error) {
rows, err := db.Query(`SELECT time, action, target, detail, user, ip, user_agent, success_set, success, error FROM audit_logs ORDER BY id`)
if err != nil {
return nil, err
}
defer rows.Close()
result := []AuditLog{}
for rows.Next() {
var log AuditLog
var successSet, success int
if err := rows.Scan(&log.Time, &log.Action, &log.Target, &log.Detail, &log.User, &log.IP, &log.UserAgent, &successSet, &success, &log.Error); err != nil {
return nil, err
}
if successSet != 0 {
value := success != 0
log.Success = &value
}
result = append(result, log)
}
return result, rows.Err()
}
func loadTasks() ([]SavedTask, error) {
rows, err := db.Query(`SELECT
id, type, container_id, container_name, status, error, created_at, template_id, user,
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
FROM tasks ORDER BY created_at, id`)
if err != nil {
return nil, err
}
defer rows.Close()
result := []SavedTask{}
configs := []savedTaskConfig{}
for rows.Next() {
var t SavedTask
var cfg savedTaskConfig
var assignIPv6 int
if err := rows.Scan(
&t.ID, &t.Type, &t.ContainerID, &t.ContainerName, &t.Status, &t.Error, &t.CreatedAt, &t.TemplateID, &t.User,
&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,
); err != nil {
return nil, err
}
cfg.AssignIPv6 = assignIPv6 != 0
result = append(result, t)
configs = append(configs, cfg)
}
if err := rows.Err(); err != nil {
return nil, err
}
if err := rows.Close(); err != nil {
return nil, err
}
for i := range result {
configs[i].ExtraPorts, err = loadTaskExtraPorts(result[i].ID)
if err != nil {
return nil, err
}
result[i].Config = encodeSavedTaskConfig(configs[i])
}
return result, nil
}
func loadTaskExtraPorts(taskID string) ([]int, error) {
rows, err := db.Query(`SELECT port FROM task_extra_ports WHERE task_id = ? ORDER BY position`, taskID)
if err != nil {
return nil, err
}
defer rows.Close()
result := []int{}
for rows.Next() {
var port int
if err := rows.Scan(&port); err != nil {
return nil, err
}
result = append(result, port)
}
return result, rows.Err()
}
func loadLoginLogs() ([]SavedLoginLog, error) {
rows, err := db.Query(`SELECT time, username, ip, user_agent, success FROM login_logs ORDER BY id`)
if err != nil {
return nil, err
}
defer rows.Close()
result := []SavedLoginLog{}
for rows.Next() {
var log SavedLoginLog
var success int
if err := rows.Scan(&log.Time, &log.Username, &log.IP, &log.UserAgent, &success); err != nil {
return nil, err
}
log.Success = success != 0
result = append(result, log)
}
return result, rows.Err()
}
func loadEnabledImages() ([]string, error) {
rows, err := db.Query(`SELECT image_id FROM enabled_images ORDER BY position`)
if err != nil {
return nil, err
}
defer rows.Close()
result := []string{}
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, err
}
result = append(result, id)
}
return result, rows.Err()
}
func loadSnapshots() ([]Snapshot, error) {
rows, err := db.Query(`SELECT id, container_id, container_name, lxc_name, created_at, created_by, scheduled, path, size_bytes FROM snapshots ORDER BY created_at, id`)
if err != nil {
return nil, err
}
defer rows.Close()
result := []Snapshot{}
for rows.Next() {
var snapshot Snapshot
var scheduled int
if err := rows.Scan(&snapshot.ID, &snapshot.ContainerID, &snapshot.ContainerName, &snapshot.LXCName, &snapshot.CreatedAt, &snapshot.CreatedBy, &scheduled, &snapshot.Path, &snapshot.SizeBytes); err != nil {
return nil, err
}
snapshot.Scheduled = scheduled != 0
result = append(result, snapshot)
}
return result, rows.Err()
}
func loadLegacyJSONConfig(path string) (*ClicdConfig, bool, error) {
data, err := os.ReadFile(path)
if os.IsNotExist(err) {
return nil, false, nil
}
if err != nil {
return nil, false, fmt.Errorf("failed to read legacy config: %v", err)
}
cfg := &ClicdConfig{}
if err := json.Unmarshal(data, cfg); err != nil {
return nil, false, fmt.Errorf("failed to parse legacy config: %v", err)
}
return cfg, true, nil
}
func boolInt(value bool) int {
if value {
return 1
}
return 0
}
func btoa(value bool) string {
if value {
return "1"
}
return "0"
}
func atob(value string) bool {
return value == "1" || strings.EqualFold(value, "true")
}
func atoi(value string) int {
n, _ := strconv.Atoi(value)
return n
}
@@ -0,0 +1,126 @@
package config
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
func TestSQLiteConfigMigratesLegacyJSONAndPersists(t *testing.T) {
resetConfigStoreForTest(t)
dir := t.TempDir()
t.Cleanup(func() {
resetConfigStoreForTest(t)
})
legacyPath := filepath.Join(dir, "config.json")
SetConfigPath(legacyPath)
legacy := ClicdConfig{
AdminUser: "admin",
AdminPassHash: "hash",
JWTSecret: "secret",
Port: 8999,
DataDir: dir,
NextContainerID: 2,
NextVNCPort: 5900,
NextSSHPort: 22000,
Containers: []Container{{
ID: 1,
UUID: "uuid-1",
Name: "ct1",
Virtualization: "lxc",
Template: "debian-12",
Status: "running",
PortMappingLimit: 2,
SnapshotLimit: 3,
PortMappings: []PortMapping{{
ContainerPort: 22,
HostPort: 22001,
Protocol: "tcp",
Description: "SSH",
}},
}},
AuditLogs: []AuditLog{{
Time: "2026-06-07 17:29:00",
Action: "security_horizontal_scan",
Target: "ct1",
Detail: "[medium] 可疑横向探测",
User: "system",
}},
LoginLogs: []SavedLoginLog{{
Time: "2026-06-07 17:29:01 CST",
Username: "admin",
IP: "127.0.0.1",
UserAgent: "test",
Success: true,
}},
Tasks: []SavedTask{{
ID: "task-1",
Type: "create",
ContainerName: "ct2",
Status: "pending",
CreatedAt: "2026-06-07 17:29:02",
Config: `{"name":"ct2","template_id":"debian-12","vcpu":1,"ram_mb":512,"disk_gb":5,"extra_ports":[80,443],"assign_ipv6":true}`,
}},
EnabledImages: []string{"debian-12"},
Snapshots: []Snapshot{{
ID: "snap-1",
ContainerID: 1,
ContainerName: "ct1",
LXCName: "ct-1",
CreatedAt: "2026-06-07 17:30:00",
Path: filepath.Join(dir, "snap-1"),
}},
}
data, err := json.Marshal(legacy)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(legacyPath, data, 0600); err != nil {
t.Fatal(err)
}
cfg, err := InitConfig()
if err != nil {
t.Fatal(err)
}
if len(cfg.Containers) != 1 || len(cfg.Containers[0].PortMappings) != 1 {
t.Fatalf("legacy config was not migrated: %+v", cfg.Containers)
}
if len(cfg.Tasks) != 1 || !strings.Contains(cfg.Tasks[0].Config, `"extra_ports":[80,443]`) {
t.Fatalf("task config was not restored from sqlite columns: %+v", cfg.Tasks)
}
if _, err := os.Stat(filepath.Join(dir, "config.db")); err != nil {
t.Fatalf("sqlite database was not created: %v", err)
}
cfg.Containers[0].Status = "stopped"
if err := SaveConfig(); err != nil {
t.Fatal(err)
}
resetConfigStoreForTest(t)
SetConfigPath(legacyPath)
cfg, err = InitConfig()
if err != nil {
t.Fatal(err)
}
if got := cfg.Containers[0].Status; got != "stopped" {
t.Fatalf("expected sqlite value to win after migration, got %q", got)
}
}
func resetConfigStoreForTest(t *testing.T) {
t.Helper()
if db != nil {
if err := db.Close(); err != nil {
t.Fatal(err)
}
db = nil
}
AppConfig = nil
configPath = ""
}
File diff suppressed because it is too large Load Diff
+95
View File
@@ -0,0 +1,95 @@
package kvm
import (
"crypto/ed25519"
"crypto/rand"
"reflect"
"testing"
"clicd/internal/config"
"golang.org/x/crypto/ssh"
)
func TestChpasswdStdinPreservesShellMetacharacters(t *testing.T) {
password := `pa'";$(touch /tmp/pwned); echo #\\word`
got, err := chpasswdStdin("root", password)
if err != nil {
t.Fatalf("chpasswdStdin returned error: %v", err)
}
want := []byte("root:" + password + "\n")
if !reflect.DeepEqual(got, want) {
t.Fatalf("chpasswdStdin = %#v, want %#v", got, want)
}
}
func TestChpasswdStdinRejectsNewlines(t *testing.T) {
tests := []struct {
name string
username string
password string
}{
{name: "username newline", username: "root\nadmin", password: "safe"},
{name: "username colon", username: "root:admin", password: "safe"},
{name: "password newline", username: "root", password: "safe\nroot:evil"},
{name: "password carriage return", username: "root", password: "safe\rroot:evil"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if _, err := chpasswdStdin(tc.username, tc.password); err == nil {
t.Fatal("chpasswdStdin returned nil error")
}
})
}
}
func TestVerifyKVMHostKeyCapturesAndRejectsMismatch(t *testing.T) {
key1 := testSSHPublicKey(t)
key2 := testSSHPublicKey(t)
saves := 0
c := &config.Container{}
save := func() error {
saves++
return nil
}
if err := verifyKVMHostKey(c, key1, save); err != nil {
t.Fatalf("first host key verification returned error: %v", err)
}
if c.SSHHostKey == "" {
t.Fatal("first host key verification did not capture fingerprint")
}
if c.SSHHostKey != sshHostKeyFingerprint(key1) {
t.Fatalf("captured fingerprint = %q, want %q", c.SSHHostKey, sshHostKeyFingerprint(key1))
}
if saves != 1 {
t.Fatalf("save count = %d, want 1", saves)
}
if err := verifyKVMHostKey(c, key1, save); err != nil {
t.Fatalf("same host key verification returned error: %v", err)
}
if saves != 1 {
t.Fatalf("save count after same key = %d, want 1", saves)
}
if err := verifyKVMHostKey(c, key2, save); err == nil {
t.Fatal("mismatched host key verification returned nil error")
}
}
func testSSHPublicKey(t *testing.T) ssh.PublicKey {
t.Helper()
_, privateKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
signer, err := ssh.NewSignerFromKey(privateKey)
if err != nil {
t.Fatal(err)
}
return signer.PublicKey()
}
+127
View File
@@ -0,0 +1,127 @@
package kvm
import (
"path/filepath"
)
type Image struct {
ID string `json:"id"`
Name string `json:"name"`
Distro string `json:"distro"`
Release string `json:"release"`
Arch string `json:"arch"`
Description string `json:"description"`
URL string `json:"url"`
Desktop string `json:"desktop,omitempty"`
}
func GetImages() []Image {
return []Image{
{
ID: "kvm-ubuntu-noble", Name: "Ubuntu 24.04 KVM",
Distro: "ubuntu", Release: "noble", Arch: "amd64",
Description: "Ubuntu 24.04 LTS cloud image for KVM",
URL: "https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img",
},
{
ID: "kvm-ubuntu-noble-xfce", Name: "Ubuntu 24.04 XFCE KVM",
Distro: "ubuntu", Release: "noble", Arch: "amd64",
Description: "Ubuntu 24.04 LTS cloud image with XFCE desktop provisioned via cloud-init",
URL: "https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img",
Desktop: "xfce",
},
{
ID: "kvm-ubuntu-jammy", Name: "Ubuntu 22.04 KVM",
Distro: "ubuntu", Release: "jammy", Arch: "amd64",
Description: "Ubuntu 22.04 LTS cloud image for KVM",
URL: "https://cloud-images.ubuntu.com/jammy/current/jammy-server-cloudimg-amd64.img",
},
{
ID: "kvm-debian-bookworm", Name: "Debian 12 KVM",
Distro: "debian", Release: "bookworm", Arch: "amd64",
Description: "Debian 12 generic cloud image for KVM",
URL: "https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-amd64.qcow2",
},
{
ID: "kvm-debian-bookworm-xfce", Name: "Debian 12 XFCE KVM",
Distro: "debian", Release: "bookworm", Arch: "amd64",
Description: "Debian 12 generic cloud image with XFCE desktop provisioned via cloud-init",
URL: "https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-amd64.qcow2",
Desktop: "xfce",
},
{
ID: "kvm-debian-bullseye", Name: "Debian 11 KVM",
Distro: "debian", Release: "bullseye", Arch: "amd64",
Description: "Debian 11 generic cloud image for KVM",
URL: "https://cloud.debian.org/images/cloud/bullseye/latest/debian-11-genericcloud-amd64.qcow2",
},
{
ID: "kvm-alpine-3.23", Name: "Alpine 3.23 KVM",
Distro: "alpine", Release: "3.23", Arch: "amd64",
Description: "Alpine Linux 3.23 NoCloud cloud-init image for KVM",
URL: "https://dev.alpinelinux.org/~tomalok/alpine-cloud-images/v3.23/nocloud/x86_64/nocloud_alpine-3.23.4-x86_64-bios-cloudinit-r0.qcow2",
},
{
ID: "kvm-centos-9-stream", Name: "CentOS Stream 9 KVM",
Distro: "centos", Release: "9-stream", Arch: "amd64",
Description: "CentOS Stream 9 GenericCloud image for KVM",
URL: "https://cloud.centos.org/centos/9-stream/x86_64/images/CentOS-Stream-GenericCloud-9-latest.x86_64.qcow2",
},
{
ID: "kvm-archlinux-current", Name: "Arch Linux KVM",
Distro: "archlinux", Release: "current", Arch: "amd64",
Description: "Arch Linux (Rolling) cloud image for KVM",
URL: "https://geo.mirror.pkgbuild.com/images/latest/Arch-Linux-x86_64-cloudimg.qcow2",
},
{
ID: "kvm-fedora-44", Name: "Fedora 44 KVM",
Distro: "fedora", Release: "44", Arch: "amd64",
Description: "Fedora 44 GenericCloud image for KVM",
URL: "https://download.fedoraproject.org/pub/fedora/linux/releases/44/Cloud/x86_64/images/Fedora-Cloud-Base-Generic-44-1.7.x86_64.qcow2",
},
{
ID: "kvm-rockylinux-9", Name: "Rocky Linux 9 KVM",
Distro: "rockylinux", Release: "9", Arch: "amd64",
Description: "Rocky Linux 9 GenericCloud image for KVM",
URL: "https://dl.rockylinux.org/pub/rocky/9/images/x86_64/Rocky-9-GenericCloud-Base.latest.x86_64.qcow2",
},
{
ID: "kvm-windows-10", Name: "Windows 10 KVM",
Distro: "windows", Release: "10", Arch: "amd64",
Description: "Windows 10 Enterprise LTSC Evaluation",
URL: "https://go.microsoft.com/fwlink/?LinkID=2195404",
},
}
}
func FindImage(id string) *Image {
for _, image := range GetImages() {
if image.ID == id {
return &image
}
}
return nil
}
func CacheDir() string {
return filepath.Join(BaseDir(), "images")
}
func ImagePath(id string) string {
img := FindImage(id)
ext := ".qcow2"
if img != nil && img.Distro == "windows" {
ext = ".iso"
}
return filepath.Join(CacheDir(), id+ext)
}
// IsWindowsImage returns true if the image distro is "windows".
func IsWindowsImage(id string) bool {
img := FindImage(id)
return img != nil && img.Distro == "windows"
}
func virtioWinISOPath() string {
return filepath.Join(CacheDir(), "virtio-win.iso")
}
+2 -2
View File
@@ -15,7 +15,7 @@ func IsExpired(c config.Container) bool {
// StopExpiredContainers stops running containers whose expiration date has passed. // StopExpiredContainers stops running containers whose expiration date has passed.
func (m *Manager) StopExpiredContainers(now time.Time) { func (m *Manager) StopExpiredContainers(now time.Time) {
for _, container := range config.AppConfig.Containers { for _, container := range config.AppConfig.Containers {
if !isContainerExpired(container, now) { if container.IsKVM() || !isContainerExpired(container, now) {
continue continue
} }
@@ -53,7 +53,7 @@ func (m *Manager) StopTrafficExceededContainers(now time.Time) {
saved := false saved := false
for i := range config.AppConfig.Containers { for i := range config.AppConfig.Containers {
c := &config.AppConfig.Containers[i] c := &config.AppConfig.Containers[i]
if c.Status != "running" { if c.IsKVM() || c.Status != "running" {
continue continue
} }
+191 -28
View File
@@ -11,11 +11,11 @@ import (
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"reflect"
"regexp" "regexp"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
"syscall"
"time" "time"
"clicd/internal/config" "clicd/internal/config"
@@ -73,6 +73,9 @@ func (m *Manager) WarmRunningContainersSSH() {
containers := append([]config.Container(nil), config.AppConfig.Containers...) containers := append([]config.Container(nil), config.AppConfig.Containers...)
for _, container := range containers { for _, container := range containers {
c := container c := container
if c.IsKVM() {
continue
}
status, err := m.GetContainerStatus(c.LxcName()) status, err := m.GetContainerStatus(c.LxcName())
if err != nil || status != "running" { if err != nil || status != "running" {
continue continue
@@ -102,6 +105,11 @@ func (m *Manager) updateAllRates() {
for i := range config.AppConfig.Containers { for i := range config.AppConfig.Containers {
c := &config.AppConfig.Containers[i] c := &config.AppConfig.Containers[i]
if c.IsKVM() {
delete(lastUsage, c.VirshName())
delete(rateCache, c.VirshName())
continue
}
if c.Status != "running" { if c.Status != "running" {
delete(lastUsage, c.LxcName()) delete(lastUsage, c.LxcName())
delete(rateCache, c.LxcName()) delete(rateCache, c.LxcName())
@@ -211,6 +219,7 @@ func NewManager() *Manager {
// ContainerConfig defines container creation parameters // ContainerConfig defines container creation parameters
type ContainerConfig struct { type ContainerConfig struct {
Name string `json:"name"` Name string `json:"name"`
Virtualization string `json:"virtualization,omitempty"`
TemplateID string `json:"template_id"` TemplateID string `json:"template_id"`
VCPU float64 `json:"vcpu"` VCPU float64 `json:"vcpu"`
CPUPercent int `json:"cpu_percent"` CPUPercent int `json:"cpu_percent"`
@@ -345,6 +354,7 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
ID: id, ID: id,
UUID: config.NewContainerUUID(), UUID: config.NewContainerUUID(),
Name: cfg.Name, Name: cfg.Name,
Virtualization: config.VirtualizationLXC,
Template: cfg.TemplateID, Template: cfg.TemplateID,
VCPU: cfg.VCPU, VCPU: cfg.VCPU,
RAMMB: cfg.RAMMB, RAMMB: cfg.RAMMB,
@@ -392,9 +402,10 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
// Set root password AFTER shiftRootfsForUnprivileged, // Set root password AFTER shiftRootfsForUnprivileged,
// otherwise /etc/shadow ownership breaks and SSHD cannot authenticate. // otherwise /etc/shadow ownership breaks and SSHD cannot authenticate.
setCmd := m.rootfsCommand(rootfsPath, if err := m.runRootfsCommand(rootfsPath,
"sh", "-c", fmt.Sprintf("printf '%%s:%%s\\n' root %s | chpasswd", shellQuote(sshPassword))) "sh", "-c", fmt.Sprintf("printf '%%s:%%s\\n' root %s | chpasswd", shellQuote(sshPassword))); err != nil {
setCmd.Run() fmt.Printf("Warning: failed to set root password in %s: %v\n", lxcName, err)
}
fmt.Printf("Container %d (%s) created successfully\n", id, cfg.Name) fmt.Printf("Container %d (%s) created successfully\n", id, cfg.Name)
return nil return nil
@@ -419,7 +430,7 @@ func (m *Manager) preconfigureNetwork(rootfsPath, templateID string) {
content := "auto lo\niface lo inet loopback\n\nauto eth0\niface eth0 inet dhcp\n" content := "auto lo\niface lo inet loopback\n\nauto eth0\niface eth0 inet dhcp\n"
_ = os.MkdirAll(filepath.Dir(interfaces), 0755) _ = os.MkdirAll(filepath.Dir(interfaces), 0755)
_ = os.WriteFile(interfaces, []byte(content), 0644) _ = os.WriteFile(interfaces, []byte(content), 0644)
_ = exec.Command("chroot", rootfsPath, "rc-update", "add", "networking", "boot").Run() _ = m.runRootfsCommand(rootfsPath, "rc-update", "add", "networking", "boot")
return return
} }
@@ -441,7 +452,7 @@ method=ignore
path := filepath.Join(nmDir, "eth0.nmconnection") path := filepath.Join(nmDir, "eth0.nmconnection")
_ = os.WriteFile(path, []byte(keyfile), 0600) _ = os.WriteFile(path, []byte(keyfile), 0600)
} }
_ = exec.Command("chroot", rootfsPath, "systemctl", "enable", "NetworkManager").Run() _ = m.runRootfsCommand(rootfsPath, "systemctl", "enable", "NetworkManager")
} }
networkdDir := filepath.Join(rootfsPath, "etc", "systemd", "network") networkdDir := filepath.Join(rootfsPath, "etc", "systemd", "network")
@@ -456,7 +467,7 @@ IPv6AcceptRA=no
_ = os.WriteFile(filepath.Join(networkdDir, "10-eth0.network"), []byte(network), 0644) _ = os.WriteFile(filepath.Join(networkdDir, "10-eth0.network"), []byte(network), 0644)
} }
if !isRHELFamily { if !isRHELFamily {
_ = exec.Command("chroot", rootfsPath, "systemctl", "enable", "systemd-networkd").Run() _ = m.runRootfsCommand(rootfsPath, "systemctl", "enable", "systemd-networkd")
} }
} }
@@ -465,7 +476,10 @@ func (m *Manager) preconfigureSSH(rootfsPath, password, templateID string) error
_ = templateID _ = templateID
ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second)
defer cancel() defer cancel()
cmd := m.rootfsCommand(rootfsPath, "sh", "-c", sshSetupScript(password, false)) cmd, err := m.rootfsCommand(rootfsPath, "sh", "-c", sshSetupScript(password, false))
if err != nil {
return err
}
cmd = exec.CommandContext(ctx, cmd.Path, cmd.Args[1:]...) cmd = exec.CommandContext(ctx, cmd.Path, cmd.Args[1:]...)
output, err := cmd.CombinedOutput() output, err := cmd.CombinedOutput()
if ctx.Err() == context.DeadlineExceeded { if ctx.Err() == context.DeadlineExceeded {
@@ -979,6 +993,27 @@ func parseSubIDRange(path, user string) (int, error) {
return 0, fmt.Errorf("%s must contain a %s subordinate id range with at least 65536 ids", path, user) return 0, fmt.Errorf("%s must contain a %s subordinate id range with at least 65536 ids", path, user)
} }
func (m *Manager) ensureUnprivilegedLXCPathAccess(lxcName string) error {
// Unprivileged container root maps to a subordinate host UID, so it needs
// execute permission on the LXC parent and container directories to reach
// rootfs. Some distributions create /var/lib/lxc as 750/700, which causes
// lxc-start to abort with "Could not access /var/lib/lxc".
for _, path := range []string{m.LxcPath, filepath.Join(m.LxcPath, lxcName)} {
info, err := os.Stat(path)
if err != nil {
return err
}
mode := info.Mode().Perm()
if mode&0001 != 0 {
continue
}
if err := os.Chmod(path, mode|0001); err != nil {
return fmt.Errorf("failed to fix LXC path permissions for %s: %v", path, err)
}
}
return nil
}
func (m *Manager) shiftRootfsForUnprivileged(lxcName string) error { func (m *Manager) shiftRootfsForUnprivileged(lxcName string) error {
uidBase, gidBase, err := unprivilegedIDMap() uidBase, gidBase, err := unprivilegedIDMap()
if err != nil { if err != nil {
@@ -986,6 +1021,9 @@ func (m *Manager) shiftRootfsForUnprivileged(lxcName string) error {
} }
rootfsPath := filepath.Join(m.LxcPath, lxcName, "rootfs") rootfsPath := filepath.Join(m.LxcPath, lxcName, "rootfs")
marker := filepath.Join(rootfsPath, ".clicd-unprivileged-shifted") marker := filepath.Join(rootfsPath, ".clicd-unprivileged-shifted")
if err := m.ensureUnprivilegedLXCPathAccess(lxcName); err != nil {
return err
}
if _, err := os.Stat(marker); err == nil { if _, err := os.Stat(marker); err == nil {
return nil return nil
} }
@@ -994,11 +1032,10 @@ func (m *Manager) shiftRootfsForUnprivileged(lxcName string) error {
if err != nil { if err != nil {
return err return err
} }
rootStat, ok := rootInfo.Sys().(*syscall.Stat_t) rootDev, _, _, ok := fileStatFields(rootInfo)
if !ok { if !ok {
return fmt.Errorf("failed to read rootfs device for %s", rootfsPath) return fmt.Errorf("failed to read rootfs device for %s", rootfsPath)
} }
rootDev := rootStat.Dev
if err := filepath.WalkDir(rootfsPath, func(path string, _ os.DirEntry, walkErr error) error { if err := filepath.WalkDir(rootfsPath, func(path string, _ os.DirEntry, walkErr error) error {
if walkErr != nil { if walkErr != nil {
@@ -1008,18 +1045,16 @@ func (m *Manager) shiftRootfsForUnprivileged(lxcName string) error {
if err != nil { if err != nil {
return err return err
} }
stat, ok := info.Sys().(*syscall.Stat_t) dev, uid, gid, ok := fileStatFields(info)
if !ok { if !ok {
return fmt.Errorf("failed to read uid/gid for %s", path) return fmt.Errorf("failed to read uid/gid for %s", path)
} }
if path != rootfsPath && stat.Dev != rootDev { if path != rootfsPath && dev != rootDev {
if info.IsDir() { if info.IsDir() {
return filepath.SkipDir return filepath.SkipDir
} }
return nil return nil
} }
uid := int(stat.Uid)
gid := int(stat.Gid)
if uid >= uidBase && uid < uidBase+65536 && gid >= gidBase && gid < gidBase+65536 { if uid >= uidBase && uid < uidBase+65536 && gid >= gidBase && gid < gidBase+65536 {
return nil return nil
} }
@@ -1029,7 +1064,7 @@ func (m *Manager) shiftRootfsForUnprivileged(lxcName string) error {
if gid >= 0 && gid < 65536 { if gid >= 0 && gid < 65536 {
gid += gidBase gid += gidBase
} }
return syscall.Lchown(path, uid, gid) return os.Lchown(path, uid, gid)
}); err != nil { }); err != nil {
return fmt.Errorf("failed to shift rootfs ownership for unprivileged LXC: %v", err) return fmt.Errorf("failed to shift rootfs ownership for unprivileged LXC: %v", err)
} }
@@ -1037,7 +1072,7 @@ func (m *Manager) shiftRootfsForUnprivileged(lxcName string) error {
if err := os.WriteFile(marker, []byte("1\n"), 0644); err != nil { if err := os.WriteFile(marker, []byte("1\n"), 0644); err != nil {
return err return err
} }
if err := syscall.Lchown(marker, uidBase, gidBase); err != nil { if err := os.Lchown(marker, uidBase, gidBase); err != nil {
return err return err
} }
@@ -1052,6 +1087,48 @@ func (m *Manager) shiftRootfsForUnprivileged(lxcName string) error {
return nil return nil
} }
func fileStatFields(info os.FileInfo) (dev uint64, uid int, gid int, ok bool) {
if info == nil || info.Sys() == nil {
return 0, 0, 0, false
}
stat := reflect.ValueOf(info.Sys())
if stat.Kind() == reflect.Pointer {
if stat.IsNil() {
return 0, 0, 0, false
}
stat = stat.Elem()
}
if stat.Kind() != reflect.Struct {
return 0, 0, 0, false
}
devValue, devOK := numericField(stat, "Dev")
uidValue, uidOK := numericField(stat, "Uid")
gidValue, gidOK := numericField(stat, "Gid")
if !devOK || !uidOK || !gidOK {
return 0, 0, 0, false
}
return devValue, int(uidValue), int(gidValue), true
}
func numericField(v reflect.Value, name string) (uint64, bool) {
field := v.FieldByName(name)
if !field.IsValid() {
return 0, false
}
switch field.Kind() {
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr:
return field.Uint(), true
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
value := field.Int()
if value < 0 {
return 0, false
}
return uint64(value), true
default:
return 0, false
}
}
func (m *Manager) unmountRootfsChildMounts(rootfsPath string) { func (m *Manager) unmountRootfsChildMounts(rootfsPath string) {
rootAbs, err := filepath.Abs(rootfsPath) rootAbs, err := filepath.Abs(rootfsPath)
if err != nil { if err != nil {
@@ -1812,14 +1889,17 @@ pgrep -x sshd >/dev/null 2>&1 || exit 33
} }
// ResetSSHPassword resets the root password of a container // ResetSSHPassword resets the root password of a container
func (m *Manager) ResetSSHPassword(id int) (string, error) { func (m *Manager) ResetSSHPassword(id int, password string) (string, error) {
c := config.FindContainer(id) c := config.FindContainer(id)
if c == nil { if c == nil {
return "", fmt.Errorf("container not found: %d", id) return "", fmt.Errorf("container not found: %d", id)
} }
lxcName := c.LxcName() lxcName := c.LxcName()
newPassword := generateRandomString(16) newPassword := strings.TrimSpace(password)
if newPassword == "" {
newPassword = generateRandomString(16)
}
if c.Status == "running" { if c.Status == "running" {
c.SSHPassword = newPassword c.SSHPassword = newPassword
@@ -1835,7 +1915,10 @@ func (m *Manager) ResetSSHPassword(id int) (string, error) {
if err := m.preconfigureSSH(rootfsPath, newPassword, c.Template); err != nil { if err := m.preconfigureSSH(rootfsPath, newPassword, c.Template); err != nil {
return "", fmt.Errorf("failed to configure SSH: %v", err) return "", fmt.Errorf("failed to configure SSH: %v", err)
} }
cmd := m.rootfsCommand(rootfsPath, "sh", "-c", fmt.Sprintf("printf '%%s:%%s\\n' root %s | chpasswd", shellQuote(newPassword))) cmd, err := m.rootfsCommand(rootfsPath, "sh", "-c", fmt.Sprintf("printf '%%s:%%s\\n' root %s | chpasswd", shellQuote(newPassword)))
if err != nil {
return "", err
}
output, err := cmd.CombinedOutput() output, err := cmd.CombinedOutput()
if err != nil { if err != nil {
return "", fmt.Errorf("failed to set password: %v, output: %s", err, string(output)) return "", fmt.Errorf("failed to set password: %v, output: %s", err, string(output))
@@ -1847,22 +1930,70 @@ func (m *Manager) ResetSSHPassword(id int) (string, error) {
return newPassword, nil return newPassword, nil
} }
func (m *Manager) rootfsCommand(rootfsPath string, args ...string) *exec.Cmd { func (m *Manager) rootfsCommand(rootfsPath string, args ...string) (*exec.Cmd, error) {
marker := filepath.Join(rootfsPath, ".clicd-unprivileged-shifted") cleanRootfsPath, err := m.safeRootfsPath(rootfsPath)
if err != nil {
return nil, err
}
marker := filepath.Join(cleanRootfsPath, ".clicd-unprivileged-shifted")
if _, err := os.Stat(marker); err == nil { if _, err := os.Stat(marker); err == nil {
uidBase, gidBase, mapErr := unprivilegedIDMap() uidBase, gidBase, mapErr := unprivilegedIDMap()
if mapErr == nil { if mapErr == nil {
cmdArgs := []string{ cmdArgs := []string{
"-m", fmt.Sprintf("u:0:%d:65536", uidBase), "-m", fmt.Sprintf("u:0:%d:65536", uidBase),
"-m", fmt.Sprintf("g:0:%d:65536", gidBase), "-m", fmt.Sprintf("g:0:%d:65536", gidBase),
"--", "chroot", rootfsPath, "--", "chroot", "--", cleanRootfsPath,
} }
cmdArgs = append(cmdArgs, args...) cmdArgs = append(cmdArgs, args...)
return exec.Command("lxc-usernsexec", cmdArgs...) return exec.Command("lxc-usernsexec", cmdArgs...), nil
} }
} }
cmdArgs := append([]string{rootfsPath}, args...) cmdArgs := append([]string{"--", cleanRootfsPath}, args...)
return exec.Command("chroot", cmdArgs...) return exec.Command("chroot", cmdArgs...), nil
}
func (m *Manager) runRootfsCommand(rootfsPath string, args ...string) error {
cmd, err := m.rootfsCommand(rootfsPath, args...)
if err != nil {
return err
}
return cmd.Run()
}
func (m *Manager) safeRootfsPath(rootfsPath string) (string, error) {
if rootfsPath == "" {
return "", fmt.Errorf("empty rootfs path")
}
if !filepath.IsAbs(rootfsPath) {
return "", fmt.Errorf("rootfs path must be absolute: %s", rootfsPath)
}
cleanRootfsPath := filepath.Clean(rootfsPath)
cleanLxcPath, err := filepath.Abs(m.LxcPath)
if err != nil {
return "", fmt.Errorf("failed to resolve LXC path: %v", err)
}
cleanLxcPath = filepath.Clean(cleanLxcPath)
if cleanRootfsPath == cleanLxcPath {
return "", fmt.Errorf("refusing LXC base path as rootfs: %s", cleanRootfsPath)
}
if filepath.Base(cleanRootfsPath) != "rootfs" {
return "", fmt.Errorf("refusing non-rootfs path: %s", cleanRootfsPath)
}
if filepath.Dir(cleanRootfsPath) == cleanLxcPath {
return "", fmt.Errorf("refusing rootfs directly under LXC path: %s", cleanRootfsPath)
}
rel, err := filepath.Rel(cleanLxcPath, cleanRootfsPath)
if err != nil {
return "", fmt.Errorf("failed to validate rootfs path: %v", err)
}
if rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) || filepath.IsAbs(rel) {
return "", fmt.Errorf("refusing unsafe rootfs path: %s", cleanRootfsPath)
}
return cleanRootfsPath, nil
} }
func (m *Manager) cleanupContainerStorage(lxcName string) error { func (m *Manager) cleanupContainerStorage(lxcName string) error {
@@ -1986,6 +2117,9 @@ func (m *Manager) GetContainerIP(lxcName string) (string, error) {
func (m *Manager) ListContainers() ([]config.Container, error) { func (m *Manager) ListContainers() ([]config.Container, error) {
containers := config.AppConfig.Containers containers := config.AppConfig.Containers
for i := range containers { for i := range containers {
if containers[i].IsKVM() {
continue
}
status, err := m.GetContainerStatus(containers[i].LxcName()) status, err := m.GetContainerStatus(containers[i].LxcName())
if err == nil { if err == nil {
containers[i].Status = status containers[i].Status = status
@@ -2061,6 +2195,7 @@ func (m *Manager) ImportExistingClicdContainers() ([]config.Container, error) {
UUID: config.NewContainerUUID(), UUID: config.NewContainerUUID(),
Name: name, Name: name,
LXCName: lxcName, LXCName: lxcName,
Virtualization: config.VirtualizationLXC,
Template: "imported", Template: "imported",
VCPU: 1, VCPU: 1,
RAMMB: 512, RAMMB: 512,
@@ -2188,9 +2323,10 @@ func (m *Manager) ReinstallContainer(id int, templateID string) error {
if err := m.shiftRootfsForUnprivileged(lxcName); err != nil { if err := m.shiftRootfsForUnprivileged(lxcName); err != nil {
return err return err
} }
setCmd := m.rootfsCommand(rootfsPath, if err := m.runRootfsCommand(rootfsPath,
"sh", "-c", fmt.Sprintf("printf '%%s:%%s\\n' root %s | chpasswd", shellQuote(c.SSHPassword))) "sh", "-c", fmt.Sprintf("printf '%%s:%%s\\n' root %s | chpasswd", shellQuote(c.SSHPassword))); err != nil {
setCmd.Run() fmt.Printf("Warning: failed to set root password in %s after reinstall: %v\n", lxcName, err)
}
// Update template and keep everything else the same // Update template and keep everything else the same
c.Template = templateID c.Template = templateID
@@ -2258,6 +2394,11 @@ func (m *Manager) GetResourceUsage(id int) (map[string]interface{}, error) {
usage := make(map[string]interface{}) usage := make(map[string]interface{})
// Read raw values // Read raw values
load1, load5, load15 := m.getContainerLoadAvg(lxcName)
usage["load1"] = load1
usage["load5"] = load5
usage["load15"] = load15
memUsage := readIntCommand(fmt.Sprintf( memUsage := readIntCommand(fmt.Sprintf(
"cat /sys/fs/cgroup/lxc/%[1]s/memory.current 2>/dev/null || "+ "cat /sys/fs/cgroup/lxc/%[1]s/memory.current 2>/dev/null || "+
"cat /sys/fs/cgroup/lxc.payload.%[1]s/memory.current 2>/dev/null || "+ "cat /sys/fs/cgroup/lxc.payload.%[1]s/memory.current 2>/dev/null || "+
@@ -2305,6 +2446,25 @@ func (m *Manager) GetResourceUsage(id int) (map[string]interface{}, error) {
return usage, nil return usage, nil
} }
func (m *Manager) getContainerLoadAvg(lxcName string) (float64, float64, float64) {
pid := m.getContainerInitPID(lxcName)
if pid == "" {
return 0, 0, 0
}
out, err := exec.Command("nsenter", "-t", pid, "-m", "-p", "cat", "/proc/loadavg").Output()
if err != nil {
return 0, 0, 0
}
parts := strings.Fields(string(out))
if len(parts) < 3 {
return 0, 0, 0
}
load1, _ := strconv.ParseFloat(parts[0], 64)
load5, _ := strconv.ParseFloat(parts[1], 64)
load15, _ := strconv.ParseFloat(parts[2], 64)
return load1, load5, load15
}
func (m *Manager) getContainerNetworkBytes(lxcName string) (uint64, uint64) { func (m *Manager) getContainerNetworkBytes(lxcName string) (uint64, uint64) {
pid := m.getContainerInitPID(lxcName) pid := m.getContainerInitPID(lxcName)
if pid == "" { if pid == "" {
@@ -2579,6 +2739,9 @@ func (m *Manager) AccumulateTraffic() {
delete(lastTrafficSnapshot, c.LxcName()) delete(lastTrafficSnapshot, c.LxcName())
continue continue
} }
if c.IsKVM() {
continue
}
// Reset if new month // Reset if new month
if c.TrafficResetDate != currentMonth { if c.TrafficResetDate != currentMonth {
c.TrafficUsedRX = 0 c.TrafficUsedRX = 0
+83
View File
@@ -0,0 +1,83 @@
package lxc
import (
"os"
"path/filepath"
"reflect"
"strings"
"testing"
)
func TestRootfsCommandAddsSeparatorAndPreservesArgs(t *testing.T) {
base := t.TempDir()
rootfs := filepath.Join(base, "ct-1", "rootfs")
if err := os.MkdirAll(rootfs, 0755); err != nil {
t.Fatal(err)
}
m := &Manager{LxcPath: base}
cmd, err := m.rootfsCommand(rootfs, "sh", "-c", "true", "--flag")
if err != nil {
t.Fatalf("rootfsCommand returned error: %v", err)
}
want := []string{"chroot", "--", rootfs, "sh", "-c", "true", "--flag"}
if !reflect.DeepEqual(cmd.Args, want) {
t.Fatalf("cmd.Args = %#v, want %#v", cmd.Args, want)
}
}
func TestRootfsCommandAllowsLeadingDashContainerName(t *testing.T) {
base := t.TempDir()
rootfs := filepath.Join(base, "-ct", "rootfs")
if err := os.MkdirAll(rootfs, 0755); err != nil {
t.Fatal(err)
}
m := &Manager{LxcPath: base}
cmd, err := m.rootfsCommand(rootfs, "true")
if err != nil {
t.Fatalf("rootfsCommand returned error: %v", err)
}
want := []string{"chroot", "--", rootfs, "true"}
if !reflect.DeepEqual(cmd.Args, want) {
t.Fatalf("cmd.Args = %#v, want %#v", cmd.Args, want)
}
}
func TestRootfsCommandRejectsUnsafeRootfsPaths(t *testing.T) {
base := t.TempDir()
outside := t.TempDir()
m := &Manager{LxcPath: base}
tests := []struct {
name string
path string
}{
{name: "outside base", path: filepath.Join(outside, "ct-1", "rootfs")},
{name: "base path", path: base},
{name: "not rootfs", path: filepath.Join(base, "ct-1", "not-rootfs")},
{name: "rootfs directly under base", path: filepath.Join(base, "rootfs")},
{name: "relative rootfs", path: filepath.Join("ct-1", "rootfs")},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if _, err := m.rootfsCommand(tc.path, "true"); err == nil {
t.Fatalf("rootfsCommand(%q) returned nil error", tc.path)
}
})
}
}
func TestSafeRootfsPathRejectsSiblingPrefix(t *testing.T) {
parent := t.TempDir()
base := filepath.Join(parent, "lxc")
siblingRootfs := filepath.Join(parent, "lxc-evil", "ct-1", "rootfs")
m := &Manager{LxcPath: base}
if _, err := m.safeRootfsPath(siblingRootfs); err == nil || !strings.Contains(err.Error(), "unsafe rootfs path") {
t.Fatalf("safeRootfsPath returned %v, want unsafe rootfs path error", err)
}
}
+47 -12
View File
@@ -18,8 +18,14 @@ func (m *Manager) ApplyPortMappings(id int) error {
return fmt.Errorf("container has no IP") return fmt.Errorf("container has no IP")
} }
tag := clicdTag(id) tag := clicdTag(id)
bridge := "lxcbr0"
subnet := "10.0.3.0/24"
if c.IsKVM() {
bridge = "virbr0"
subnet = "192.168.122.0/24"
}
EnsureForwardRules() EnsureForwardRules(bridge)
m.CleanPortMappings(id) m.CleanPortMappings(id)
for _, pm := range c.PortMappings { for _, pm := range c.PortMappings {
@@ -41,8 +47,8 @@ func (m *Manager) ApplyPortMappings(id int) error {
fmt.Printf("Port mapping: host:%d -> %s:%d\n", 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", "10.0.3.0/24", "-o", "eth+", "-j", "MASQUERADE").Run() != nil { 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", "10.0.3.0/24", "-o", "eth+", "-j", "MASQUERADE").Run() exec.Command("iptables", "-t", "nat", "-I", "POSTROUTING", "1", "-s", subnet, "-o", "eth+", "-j", "MASQUERADE").Run()
} }
return nil return nil
@@ -50,18 +56,25 @@ func (m *Manager) ApplyPortMappings(id int) error {
func clicdTag(id int) string { return "c" + strconv.Itoa(id) } func clicdTag(id int) string { return "c" + strconv.Itoa(id) }
// EnsureForwardRules makes sure iptables FORWARD chain allows LXC bridge traffic // EnsureForwardRules makes sure iptables FORWARD chain allows bridge traffic.
func EnsureForwardRules() { func EnsureForwardRules(bridge string) {
if bridge == "" {
bridge = "lxcbr0"
}
rules := [][]string{ rules := [][]string{
{"-A", "FORWARD", "-i", "lxcbr0", "-j", "ACCEPT"}, {"-i", bridge, "-j", "ACCEPT"},
{"-A", "FORWARD", "-o", "lxcbr0", "-j", "ACCEPT"}, {"-o", bridge, "-j", "ACCEPT"},
{"-A", "FORWARD", "-i", "lxcbr0", "-o", "lxcbr0", "-j", "ACCEPT"}, {"-i", bridge, "-o", bridge, "-j", "ACCEPT"},
} }
for _, args := range rules { for _, args := range rules {
checkArgs := append([]string{"-C", "FORWARD"}, args[2:]...) for {
if exec.Command("iptables", checkArgs...).Run() != nil { deleteArgs := append([]string{"-D", "FORWARD"}, args...)
exec.Command("iptables", args...).Run() if exec.Command("iptables", deleteArgs...).Run() != nil {
break
}
} }
insertArgs := append([]string{"-I", "FORWARD", "1"}, args...)
exec.Command("iptables", insertArgs...).Run()
} }
} }
@@ -161,12 +174,24 @@ func normalizePortMapping(c *config.Container, skipIndex int, pm config.PortMapp
if pm.HostPort <= 0 { if pm.HostPort <= 0 {
pm.HostPort = pm.ContainerPort pm.HostPort = pm.ContainerPort
} }
// Check current container's own mappings
for i, existing := range c.PortMappings { for i, existing := range c.PortMappings {
if i == skipIndex { if i == skipIndex {
continue continue
} }
if existing.HostPort == pm.HostPort && existing.Protocol == pm.Protocol { if existing.HostPort == pm.HostPort && existing.Protocol == pm.Protocol {
return pm, fmt.Errorf("host port %d/%s already mapped", pm.HostPort, pm.Protocol) return pm, fmt.Errorf("host port %d/%s already mapped in this container", pm.HostPort, pm.Protocol)
}
}
// Check all other containers (LXC + KVM) for port conflicts
for _, oc := range config.AppConfig.Containers {
if oc.ID == c.ID {
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)
}
} }
} }
return pm, nil return pm, nil
@@ -177,10 +202,20 @@ func allocateDefaultEqualPorts(c *config.Container, count int) []int {
return nil return nil
} }
used := map[int]bool{} used := map[int]bool{}
// Mark current container's ports
for _, pm := range c.PortMappings { for _, pm := range c.PortMappings {
used[pm.HostPort] = true used[pm.HostPort] = true
used[pm.ContainerPort] = true used[pm.ContainerPort] = true
} }
// Also mark all other containers' host ports (LXC + KVM)
for _, oc := range config.AppConfig.Containers {
if oc.ID == c.ID {
continue
}
for _, pm := range oc.PortMappings {
used[pm.HostPort] = true
}
}
ports := make([]int, 0, count) ports := make([]int, 0, count)
next := 20000 next := 20000
for len(ports) < count { for len(ports) < count {
+1 -1
View File
@@ -207,7 +207,7 @@ func (m *Manager) runDueSnapshotSchedules() {
now := time.Now() now := time.Now()
containers := append([]config.Container(nil), config.AppConfig.Containers...) containers := append([]config.Container(nil), config.AppConfig.Containers...)
for _, c := range containers { for _, c := range containers {
if !c.SnapshotScheduleEnabled { if c.IsKVM() || !c.SnapshotScheduleEnabled {
continue continue
} }
nextRun, err := time.Parse(time.RFC3339, c.SnapshotScheduleNextRun) nextRun, err := time.Parse(time.RFC3339, c.SnapshotScheduleNextRun)
+4 -4
View File
@@ -80,6 +80,7 @@ func setupRoutes(mux *http.ServeMux) {
mux.HandleFunc("/api/templates", corsMiddleware(api.AuthMiddleware(api.HandleTemplates))) mux.HandleFunc("/api/templates", corsMiddleware(api.AuthMiddleware(api.HandleTemplates)))
mux.HandleFunc("/api/images", corsMiddleware(api.AdminMiddleware(api.HandleImages))) mux.HandleFunc("/api/images", corsMiddleware(api.AdminMiddleware(api.HandleImages)))
mux.HandleFunc("/api/images/download", corsMiddleware(api.AdminMiddleware(api.HandleImageDownload))) mux.HandleFunc("/api/images/download", corsMiddleware(api.AdminMiddleware(api.HandleImageDownload)))
mux.HandleFunc("/api/images/cancel", corsMiddleware(api.AdminMiddleware(api.HandleImageCancel)))
mux.HandleFunc("/api/images/delete", corsMiddleware(api.AdminMiddleware(api.HandleImageDelete))) mux.HandleFunc("/api/images/delete", corsMiddleware(api.AdminMiddleware(api.HandleImageDelete)))
mux.HandleFunc("/api/images/toggle", corsMiddleware(api.AdminMiddleware(api.HandleImageToggle))) mux.HandleFunc("/api/images/toggle", corsMiddleware(api.AdminMiddleware(api.HandleImageToggle)))
mux.HandleFunc("/api/images/enabled", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleEnabledImages)))) mux.HandleFunc("/api/images/enabled", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleEnabledImages))))
@@ -88,9 +89,6 @@ func setupRoutes(mux *http.ServeMux) {
mux.HandleFunc("/api/snapshots", corsMiddleware(api.AdminMiddleware(api.HandleSnapshots))) mux.HandleFunc("/api/snapshots", corsMiddleware(api.AdminMiddleware(api.HandleSnapshots)))
mux.HandleFunc("/api/routing", corsMiddleware(api.AdminMiddleware(api.HandleRouting))) mux.HandleFunc("/api/routing", corsMiddleware(api.AdminMiddleware(api.HandleRouting)))
mux.HandleFunc("/api/ipv6/status", corsMiddleware(api.AdminMiddleware(api.HandleIPv6Status))) mux.HandleFunc("/api/ipv6/status", corsMiddleware(api.AdminMiddleware(api.HandleIPv6Status)))
mux.HandleFunc("/api/oversell", corsMiddleware(api.AdminMiddleware(api.HandleOversell)))
mux.HandleFunc("/api/oversell/status", corsMiddleware(api.AdminMiddleware(api.HandleOversellStatus)))
mux.HandleFunc("/api/oversell/reclaim", corsMiddleware(api.AdminMiddleware(api.HandleOversellReclaim)))
mux.HandleFunc("/api/tasks", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleTasks)))) mux.HandleFunc("/api/tasks", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleTasks))))
mux.HandleFunc("/api/tasks/", corsMiddleware(api.AuthMiddleware(api.AdminMiddleware(api.HandleTaskDelete)))) mux.HandleFunc("/api/tasks/", corsMiddleware(api.AuthMiddleware(api.AdminMiddleware(api.HandleTaskDelete))))
mux.HandleFunc("/api/batch-create", corsMiddleware(api.AdminMiddleware(api.HandleBatchCreate))) mux.HandleFunc("/api/batch-create", corsMiddleware(api.AdminMiddleware(api.HandleBatchCreate)))
@@ -105,8 +103,11 @@ func setupRoutes(mux *http.ServeMux) {
mux.HandleFunc("/api/security/check", corsMiddleware(api.AdminMiddleware(api.HandleSecurityCheck))) mux.HandleFunc("/api/security/check", corsMiddleware(api.AdminMiddleware(api.HandleSecurityCheck)))
mux.HandleFunc("/api/security/logs", corsMiddleware(api.AdminMiddleware(api.HandleSecurityLogs))) mux.HandleFunc("/api/security/logs", corsMiddleware(api.AdminMiddleware(api.HandleSecurityLogs)))
mux.HandleFunc("/api/security/summary", corsMiddleware(api.AdminMiddleware(api.HandleContainerSecuritySummary))) mux.HandleFunc("/api/security/summary", corsMiddleware(api.AdminMiddleware(api.HandleContainerSecuritySummary)))
mux.HandleFunc("/api/security/settings", corsMiddleware(api.AdminMiddleware(api.HandleSecuritySettings)))
mux.HandleFunc("/api/ssh-ticket", corsMiddleware(api.AuthMiddleware(api.HandleWebSSHTicket))) mux.HandleFunc("/api/ssh-ticket", corsMiddleware(api.AuthMiddleware(api.HandleWebSSHTicket)))
mux.HandleFunc("/api/ssh", api.HandleWebSSH) // WebSocket mux.HandleFunc("/api/ssh", api.HandleWebSSH) // WebSocket
mux.HandleFunc("/api/vnc-ticket", corsMiddleware(api.AuthMiddleware(api.HandleVNCTicket)))
mux.HandleFunc("/api/vnc", api.HandleVNCProxy) // WebSocket
// API Key management // API Key management
mux.HandleFunc("/api/api-keys", corsMiddleware(api.AdminMiddleware(api.HandleApiKeys))) mux.HandleFunc("/api/api-keys", corsMiddleware(api.AdminMiddleware(api.HandleApiKeys)))
@@ -164,4 +165,3 @@ func Run() error {
return server.ListenAndServe() return server.ListenAndServe()
} }
+1 -6
View File
@@ -1,7 +1,7 @@
package version package version
var ( var (
Version = "1.0.10" Version = "1.1.4"
Repo = "MengMengCode/CLICD" Repo = "MengMengCode/CLICD"
) )
@@ -11,8 +11,3 @@ func Current() string {
} }
return Version return Version
} }
+13 -5
View File
@@ -9,6 +9,7 @@ import (
"clicd/internal/api" "clicd/internal/api"
"clicd/internal/cli" "clicd/internal/cli"
"clicd/internal/config" "clicd/internal/config"
"clicd/internal/kvm"
"clicd/internal/lxc" "clicd/internal/lxc"
"clicd/internal/server" "clicd/internal/server"
@@ -50,18 +51,25 @@ func main() {
// Start security scanner // Start security scanner
api.InitScanner() api.InitScanner()
// Ensure iptables FORWARD rules allow LXC traffic // Ensure iptables FORWARD rules allow managed bridge traffic.
lxc.EnsureForwardRules() lxc.EnsureForwardRules("lxcbr0")
lxc.EnsureForwardRules("virbr0")
// Start expiry scanner (stops expired containers every 30s) // Start expiry scanners (stops expired/over-traffic workloads every 30s)
manager := lxc.NewManager() manager := lxc.NewManager()
kvmManager := kvm.NewManager()
manager.StartExpiryScanner() manager.StartExpiryScanner()
kvmManager.StartExpiryScanner()
// Start usage monitor (computes CPU/network/disk rates every 5s) // Start usage monitors (computes CPU/network/disk rates every 5s)
manager.StartUsageMonitor() manager.StartUsageMonitor()
kvmManager.StartUsageMonitor()
kvmManager.StartNetworkSyncMonitor()
kvmManager.StartIPv6Guard()
// Start scheduled snapshot scanner. // Start scheduled snapshot scanners.
manager.StartSnapshotScheduler() manager.StartSnapshotScheduler()
kvmManager.StartSnapshotScheduler()
// Clean up stale container configs (LXC dir was deleted but config remains) // Clean up stale container configs (LXC dir was deleted but config remains)
config.CleanStaleContainers() config.CleanStaleContainers()
+566 -709
View File
File diff suppressed because it is too large Load Diff
+4 -3
View File
@@ -1,7 +1,7 @@
{ {
"name": "clicd-frontend", "name": "clicd-frontend",
"private": true, "private": true,
"version": "1.0.0", "version": "1.1.4",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@@ -9,6 +9,7 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@novnc/novnc": "1.5.0",
"@xterm/addon-fit": "^0.11.0", "@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0", "@xterm/xterm": "^6.0.0",
"axios": "^1.7.7", "axios": "^1.7.7",
@@ -20,11 +21,11 @@
"devDependencies": { "devDependencies": {
"@types/react": "^18.3.12", "@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1", "@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^5.2.0",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"postcss": "^8.4.49", "postcss": "^8.4.49",
"tailwindcss": "^3.4.15", "tailwindcss": "^3.4.15",
"typescript": "^5.6.3", "typescript": "^5.6.3",
"vite": "^5.4.11" "vite": "^8.0.16"
} }
} }
+2 -2
View File
@@ -4,7 +4,7 @@ import Login from './pages/Login'
import Dashboard from './pages/Dashboard' import Dashboard from './pages/Dashboard'
import Containers from './pages/Containers' import Containers from './pages/Containers'
import ContainerDetail from './pages/ContainerDetail' import ContainerDetail from './pages/ContainerDetail'
import Oversell from './pages/Oversell'
import Security from './pages/Security' import Security from './pages/Security'
import AuditLogs from './pages/AuditLogs' import AuditLogs from './pages/AuditLogs'
import ApiIntegration from './pages/ApiIntegration' import ApiIntegration from './pages/ApiIntegration'
@@ -58,7 +58,7 @@ function App() {
<Route path="containers" element={<Containers />} /> <Route path="containers" element={<Containers />} />
<Route path="images" element={<ImageManagement />} /> <Route path="images" element={<ImageManagement />} />
<Route path="container/:id" element={<ContainerDetail />} /> <Route path="container/:id" element={<ContainerDetail />} />
<Route path="oversell" element={<Oversell />} />
<Route path="security" element={<Security />} /> <Route path="security" element={<Security />} />
<Route path="snapshots" element={<Snapshots />} /> <Route path="snapshots" element={<Snapshots />} />
<Route path="routing" element={<Routing />} /> <Route path="routing" element={<Routing />} />
+1 -1
View File
@@ -56,7 +56,7 @@ export default function ContainerCard({ container, onRefresh }: ContainerCardPro
{/* Header */} {/* Header */}
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-10 h-10 bg-gray-100 rounded-lg flex items-center justify-center"> <div className="w-10 h-10 flex items-center justify-center">
<Server className="w-5 h-5 text-gray-700" /> <Server className="w-5 h-5 text-gray-700" />
</div> </div>
<div> <div>
+151 -29
View File
@@ -12,6 +12,7 @@ interface CreateContainerModalProps {
const defaultForm: CreateContainerRequest = { const defaultForm: CreateContainerRequest = {
name: '', name: '',
virtualization: 'lxc',
template_id: '', template_id: '',
vcpu: 1, vcpu: 1,
cpu_percent: 100, cpu_percent: 100,
@@ -25,7 +26,7 @@ const defaultForm: CreateContainerRequest = {
io_speed_mbps: 0, io_speed_mbps: 0,
extra_ports: [], extra_ports: [],
port_mapping_count: 2, port_mapping_count: 2,
snapshot_limit: 3, snapshot_limit: 1,
assign_ipv6: false, assign_ipv6: false,
expires_at: '', expires_at: '',
} }
@@ -43,13 +44,14 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
useEffect(() => { useEffect(() => {
if (!isOpen) return if (!isOpen) return
getEnabledImages() getEnabledImages(form.virtualization)
.then((res) => { .then((res) => {
const data = res.data.data || [] const data = res.data.data || []
setTemplates(data) setTemplates(data)
if (data.length > 0) { setForm((prev) => {
setForm((prev) => ({ ...prev, template_id: prev.template_id || data[0].id })) const templateID = data.some((item) => item.id === prev.template_id) ? prev.template_id : (data[0]?.id || '')
} return applyTemplateDefaults({ ...prev, template_id: templateID })
})
}) })
.catch(console.error) .catch(console.error)
@@ -69,13 +71,14 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
getHostInfo() getHostInfo()
.then((res) => setHostInfo(res.data.data || null)) .then((res) => setHostInfo(res.data.data || null))
.catch(() => setHostInfo(null)) .catch(() => setHostInfo(null))
}, [isOpen]) }, [isOpen, form.virtualization])
const ipv6Available = !!ipv6Status?.available const ipv6Available = !!ipv6Status?.available
const ipv6Prefix = ipv6Status?.prefixes?.[0]?.prefix || '' const ipv6Prefix = ipv6Status?.prefixes?.[0]?.prefix || ''
const maxVCPU = hostInfo?.cpu.cores || 64 const maxVCPU = hostInfo?.cpu.cores || 64
const maxRAMMB = hostInfo?.ram.total_mb ? Number(hostInfo.ram.total_mb) : undefined const maxRAMMB = hostInfo?.ram.total_mb ? Number(hostInfo.ram.total_mb) : undefined
const maxDiskGB = hostInfo?.disk.total_gb ? Math.max(1, Math.floor(hostInfo.disk.total_gb)) : undefined const maxDiskGB = hostInfo?.disk.total_gb ? Math.max(1, Math.floor(hostInfo.disk.total_gb)) : undefined
const resourceErrors = validateResourceInputs(form, maxVCPU, maxRAMMB, maxDiskGB)
const autoPorts = useMemo(() => { const autoPorts = useMemo(() => {
const count = Math.max(2, form.port_mapping_count) const count = Math.max(2, form.port_mapping_count)
@@ -119,7 +122,12 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
return return
} }
const boundedForm = clampCreateForm(form, maxVCPU, maxRAMMB, maxDiskGB) if (Object.keys(resourceErrors).length > 0) {
dialog.alert('资源配置有误', '请按红色提示修改 vCPU、内存或磁盘配置')
return
}
const boundedForm = normalizeCreateForm(form)
// Build batch of containers // Build batch of containers
const containers: CreateContainerRequest[] = [] const containers: CreateContainerRequest[] = []
@@ -181,15 +189,34 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
</div> </div>
{batchCount > 1 && <p className="text-xs text-gray-400"> {batchCount} {form.name}-{batchStartIndex} {form.name}-{batchStartIndex + batchCount - 1}</p>} {batchCount > 1 && <p className="text-xs text-gray-400"> {batchCount} {form.name}-{batchStartIndex} {form.name}-{batchStartIndex + batchCount - 1}</p>}
<Field label="虚拟化架构">
<div className="grid grid-cols-2 gap-2">
<button
type="button"
onClick={() => setForm((prev) => applyTemplateDefaults({ ...prev, virtualization: 'lxc', template_id: '' }))}
className={`rounded-md border px-3 py-2 text-sm font-medium transition-colors ${form.virtualization === 'lxc' ? 'border-black bg-black text-white' : 'border-gray-300 text-gray-700 hover:bg-gray-50'}`}
>
LXC
</button>
<button
type="button"
onClick={() => setForm((prev) => applyTemplateDefaults({ ...prev, virtualization: 'kvm', template_id: '' }))}
className={`rounded-md border px-3 py-2 text-sm font-medium transition-colors ${form.virtualization === 'kvm' ? 'border-black bg-black text-white' : 'border-gray-300 text-gray-700 hover:bg-gray-50'}`}
>
KVM
</button>
</div>
</Field>
<Field label="系统模板"> <Field label="系统模板">
{templates.length === 0 ? ( {templates.length === 0 ? (
<div className="text-sm text-amber-600 bg-amber-50 border border-amber-200 rounded-md px-3 py-2"> <div className="text-sm text-amber-600 bg-amber-50 border border-amber-200 rounded-md px-3 py-2">
{form.virtualization === 'kvm' ? ' KVM' : ' LXC'}
</div> </div>
) : ( ) : (
<select <select
value={form.template_id} value={form.template_id}
onChange={(event) => setForm({ ...form, template_id: event.target.value })} onChange={(event) => setForm(applyTemplateDefaults({ ...form, template_id: event.target.value }))}
className={inputClass} className={inputClass}
> >
{templates.map((template) => ( {templates.map((template) => (
@@ -199,6 +226,7 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
))} ))}
</select> </select>
)} )}
</Field> </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'}`}> <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'}`}>
@@ -219,16 +247,39 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<Field label="vCPU"> <Field label="vCPU">
<NumberInput value={form.vcpu} min={0.25} max={maxVCPU} step={0.25} onChange={(value) => setForm({ ...form, vcpu: clampVCPU(value, maxVCPU) })} /> <NumberInput
value={form.vcpu}
min={form.virtualization === 'kvm' ? 1 : 0.25}
max={maxVCPU}
step={form.virtualization === 'kvm' ? 1 : 0.25}
invalid={!!resourceErrors.vcpu}
onChange={(value) => setForm({ ...form, vcpu: value })}
/>
{resourceErrors.vcpu && <p className="mt-1 text-xs text-red-500">{resourceErrors.vcpu}</p>}
</Field> </Field>
<Field label="内存 (MB)"> <Field label="内存 (MB)">
<NumberInput value={form.ram_mb} min={128} max={maxRAMMB} step={128} onChange={(value) => setForm({ ...form, ram_mb: clampInt(value, 128, maxRAMMB, 512) })} /> <NumberInput
value={form.ram_mb}
min={128}
max={maxRAMMB}
step={128}
invalid={!!resourceErrors.ram_mb}
onChange={(value) => setForm({ ...form, ram_mb: value })}
/>
{resourceErrors.ram_mb && <p className="mt-1 text-xs text-red-500">{resourceErrors.ram_mb}</p>}
</Field> </Field>
</div> </div>
<div className="grid grid-cols-3 gap-3"> <div className="grid grid-cols-3 gap-3">
<Field label="磁盘 (GB)"> <Field label="磁盘 (GB)">
<NumberInput value={form.disk_gb} min={1} max={maxDiskGB} onChange={(value) => setForm({ ...form, disk_gb: clampInt(value, 1, maxDiskGB, 10) })} /> <NumberInput
value={form.disk_gb}
min={1}
max={maxDiskGB}
invalid={!!resourceErrors.disk_gb}
onChange={(value) => setForm({ ...form, disk_gb: value })}
/>
{resourceErrors.disk_gb && <p className="mt-1 text-xs text-red-500">{resourceErrors.disk_gb}</p>}
</Field> </Field>
<Field label="带宽 (Mbps)"> <Field label="带宽 (Mbps)">
<NumberInput value={form.network_bw_mbps} min={0} onChange={(value) => setForm({ ...form, network_bw_mbps: value })} /> <NumberInput value={form.network_bw_mbps} min={0} onChange={(value) => setForm({ ...form, network_bw_mbps: value })} />
@@ -277,7 +328,7 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
/> />
<div className="mt-2 flex flex-wrap gap-1.5"> <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"> <span className="inline-flex px-2 py-1 bg-emerald-50 text-emerald-700 rounded text-xs font-mono">
SSH: {sshPortPreview} -&gt; 22 {isWindowsTemplate(form.template_id) ? 'RDP' : 'SSH'}: {sshPortPreview} -&gt; {isWindowsTemplate(form.template_id) ? 3389 : 22}
</span> </span>
{autoPorts.map((port) => ( {autoPorts.map((port) => (
<span key={port} className="inline-flex px-2 py-1 bg-gray-100 text-gray-700 rounded text-xs font-mono"> <span key={port} className="inline-flex px-2 py-1 bg-gray-100 text-gray-700 rounded text-xs font-mono">
@@ -342,44 +393,115 @@ function NumberInput({
min, min,
max, max,
step, step,
invalid,
onChange, onChange,
}: { }: {
value: number value: number
min?: number min?: number
max?: number max?: number
step?: number step?: number
invalid?: boolean
onChange: (value: number) => void onChange: (value: number) => void
}) { }) {
const [draft, setDraft] = useState(Number.isFinite(value) ? String(value) : '')
const [focused, setFocused] = useState(false)
useEffect(() => {
if (!focused) {
setDraft(Number.isFinite(value) ? String(value) : '')
}
}, [focused, value])
return ( return (
<input <input
type="number" type="text"
value={value} inputMode={step && !Number.isInteger(step) ? 'decimal' : 'numeric'}
min={min} value={draft}
max={max} onFocus={() => setFocused(true)}
step={step} onBlur={() => {
setFocused(false)
setDraft(Number.isFinite(value) ? String(value) : '')
}}
onChange={(event) => { onChange={(event) => {
const raw = event.target.value const raw = event.target.value
const value = step && !Number.isInteger(step) ? parseFloat(raw) : parseInt(raw, 10) setDraft(raw)
onChange(value) const next = step && !Number.isInteger(step) ? parseFloat(raw) : parseInt(raw, 10)
onChange(next)
}} }}
className={inputClass} aria-invalid={invalid || undefined}
data-min={min}
data-max={max}
data-step={step}
className={`${inputClass} ${invalid ? 'border-red-400 focus:border-red-400 focus:ring-red-400' : ''}`}
/> />
) )
} }
function clampCreateForm(form: CreateContainerRequest, maxVCPU: number, maxRAMMB?: number, maxDiskGB?: number): CreateContainerRequest { function validateResourceInputs(form: CreateContainerRequest, maxVCPU: number, maxRAMMB?: number, maxDiskGB?: number) {
const errors: Partial<Record<'vcpu' | 'ram_mb' | 'disk_gb', string>> = {}
const windows = isWindowsTemplate(form.template_id)
const minVCPU = windows ? 2 : (form.virtualization === 'kvm' ? 1 : 0.25)
const minRAMMB = windows ? 2048 : 128
const minDiskGB = windows ? 30 : 1
if (!Number.isFinite(form.vcpu)) {
errors.vcpu = '请输入 vCPU'
} else if (form.vcpu < minVCPU) {
errors.vcpu = `不能小于 ${minVCPU}`
} else if (form.vcpu > maxVCPU) {
errors.vcpu = `不能大于 ${maxVCPU}`
} else if (form.virtualization === 'kvm' && form.vcpu !== Math.round(form.vcpu)) {
errors.vcpu = 'KVM vCPU 必须是整数'
}
if (!Number.isFinite(form.ram_mb)) {
errors.ram_mb = '请输入内存'
} else if (form.ram_mb < minRAMMB) {
errors.ram_mb = `不能小于 ${minRAMMB} MB`
} else if (maxRAMMB && form.ram_mb > maxRAMMB) {
errors.ram_mb = `不能大于 ${maxRAMMB} MB`
}
if (!Number.isFinite(form.disk_gb)) {
errors.disk_gb = '请输入磁盘'
} else if (form.disk_gb < minDiskGB) {
errors.disk_gb = `不能小于 ${minDiskGB} GB`
} else if (maxDiskGB && form.disk_gb > maxDiskGB) {
errors.disk_gb = `不能大于 ${maxDiskGB} GB`
}
return errors
}
function normalizeCreateForm(form: CreateContainerRequest): CreateContainerRequest {
const normalized = applyTemplateDefaults(form)
return { return {
...form, ...normalized,
vcpu: clampVCPU(form.vcpu, maxVCPU), vcpu: normalized.virtualization === 'kvm' ? Math.round(normalized.vcpu) : normalizeLXCvCPU(normalized.vcpu),
ram_mb: clampInt(form.ram_mb, 128, maxRAMMB, 512), ram_mb: Math.round(normalized.ram_mb),
disk_gb: clampInt(form.disk_gb, 1, maxDiskGB, 10), disk_gb: Math.round(normalized.disk_gb),
snapshot_limit: clampInt(form.snapshot_limit, 1, undefined, 3), snapshot_limit: clampInt(normalized.snapshot_limit, 1, undefined, 3),
} }
} }
function clampVCPU(value: number, max: number) { function applyTemplateDefaults(form: CreateContainerRequest): CreateContainerRequest {
if (!isWindowsTemplate(form.template_id)) return form
return {
...form,
virtualization: 'kvm',
vcpu: Math.max(2, Math.round(Number.isFinite(form.vcpu) ? form.vcpu : 2)),
ram_mb: Math.max(2048, Math.round(Number.isFinite(form.ram_mb) ? form.ram_mb : 2048)),
disk_gb: Math.max(30, Math.round(Number.isFinite(form.disk_gb) ? form.disk_gb : 30)),
}
}
function isWindowsTemplate(templateID: string) {
return templateID.toLowerCase().includes('windows')
}
function normalizeLXCvCPU(value: number) {
const rounded = Math.round((Number.isFinite(value) ? value : 1) * 4) / 4 const rounded = Math.round((Number.isFinite(value) ? value : 1) * 4) / 4
return Number(Math.min(Math.max(rounded, 0.25), max).toFixed(2)) return Number(rounded.toFixed(2))
} }
function clampInt(value: number, min: number, max?: number, fallback = min) { function clampInt(value: number, min: number, max?: number, fallback = min) {
+4 -6
View File
@@ -16,8 +16,8 @@ export function RingStat({ value, max = 100, label, subLabel, size = 120, stroke
const radius = (size - strokeWidth) / 2 const radius = (size - strokeWidth) / 2
const circumference = radius * 2 * Math.PI const circumference = radius * 2 * Math.PI
const percentage = Math.min(Math.max(value / max * 100, 0), 100) const percentage = max === Infinity ? Math.max(value, 0) : Math.min(Math.max(value / max * 100, 0), 100)
const strokeDashoffset = circumference - (percentage / 100) * circumference const strokeDashoffset = circumference - (Math.min(percentage, 100) / 100) * circumference
const bgStroke = isDark ? '#374151' : '#f3f4f6' const bgStroke = isDark ? '#374151' : '#f3f4f6'
const progressStroke = isDark ? '#f9fafb' : '#000000' const progressStroke = isDark ? '#f9fafb' : '#000000'
@@ -51,7 +51,7 @@ export function RingStat({ value, max = 100, label, subLabel, size = 120, stroke
</svg> </svg>
{/* Center value */} {/* Center value */}
<div className="absolute inset-0 flex flex-col items-center justify-center"> <div className="absolute inset-0 flex flex-col items-center justify-center">
<span className="text-2xl font-bold text-black dark:text-white">{value.toFixed(percentage < 1 ? 2 : 1)}%</span> <span className="text-2xl font-bold text-black dark:text-white">{percentage.toFixed(percentage < 1 ? 2 : 1)}%</span>
</div> </div>
</div> </div>
<div className="mt-2 text-center"> <div className="mt-2 text-center">
@@ -73,7 +73,6 @@ interface RingStatsProps {
swapUsed?: number swapUsed?: number
swapTotal?: number swapTotal?: number
loadPercent: number loadPercent: number
loadStatus: string
diskPercent: number diskPercent: number
diskUsed: number diskUsed: number
diskTotal: number diskTotal: number
@@ -90,7 +89,6 @@ export default function RingStats({
swapUsed = 0, swapUsed = 0,
swapTotal = 0, swapTotal = 0,
loadPercent, loadPercent,
loadStatus,
diskPercent, diskPercent,
diskUsed, diskUsed,
diskTotal, diskTotal,
@@ -125,8 +123,8 @@ export default function RingStats({
)} )}
<RingStat <RingStat
value={loadPercent} value={loadPercent}
max={Infinity}
label="负载" label="负载"
subLabel={loadStatus}
/> />
<RingStat <RingStat
value={diskPercent} value={diskPercent}
+43 -18
View File
@@ -12,7 +12,7 @@ import {
Route, Route,
ScrollText, ScrollText,
Server, Server,
Settings2,
ShieldAlert, ShieldAlert,
Sun, Sun,
UserCog, UserCog,
@@ -27,6 +27,23 @@ interface SidebarProps {
onToggle: () => void onToggle: () => void
} }
function GitHubIcon({ 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="M512 42.666667A464.64 464.64 0 0 0 42.666667 502.186667 460.373333 460.373333 0 0 0 363.52 938.666667c23.466667 4.266667 32-9.813333 32-22.186667v-78.08c-130.56 27.733333-158.293333-61.44-158.293333-61.44a122.026667 122.026667 0 0 0-52.053334-67.413333c-42.666667-28.16 3.413333-27.733333 3.413334-27.733334a98.56 98.56 0 0 1 71.68 47.36 101.12 101.12 0 0 0 136.533333 37.973334 99.413333 99.413333 0 0 1 29.866667-61.44c-104.106667-11.52-213.333333-50.773333-213.333334-226.986667a177.066667 177.066667 0 0 1 47.36-124.16 161.28 161.28 0 0 1 4.693334-121.173333s39.68-12.373333 128 46.933333a455.68 455.68 0 0 1 234.666666 0c89.6-59.306667 128-46.933333 128-46.933333a161.28 161.28 0 0 1 4.693334 121.173333A177.066667 177.066667 0 0 1 810.666667 477.866667c0 176.64-110.08 215.466667-213.333334 226.986666a106.666667 106.666667 0 0 1 32 85.333334v125.866666c0 14.933333 8.533333 26.88 32 22.186667A460.8 460.8 0 0 0 981.333333 502.186667 464.64 464.64 0 0 0 512 42.666667"
fill="currentColor"
/>
</svg>
)
}
export default function Sidebar({ collapsed, onToggle }: SidebarProps) { export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation() const location = useLocation()
@@ -49,7 +66,7 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
location.pathname.startsWith('/container') location.pathname.startsWith('/container')
const isImagesPage = location.pathname.startsWith('/images') const isImagesPage = location.pathname.startsWith('/images')
const isOversellPage = location.pathname.startsWith('/oversell')
const isSnapshotsPage = location.pathname.startsWith('/snapshots') const isSnapshotsPage = location.pathname.startsWith('/snapshots')
const isRoutingPage = location.pathname.startsWith('/routing') const isRoutingPage = location.pathname.startsWith('/routing')
const isAuditLogsPage = location.pathname.startsWith('/audit-logs') const isAuditLogsPage = location.pathname.startsWith('/audit-logs')
@@ -66,14 +83,14 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
<div className="flex items-center justify-between h-14 px-4 border-b border-gray-200 dark:border-gray-700"> <div className="flex items-center justify-between h-14 px-4 border-b border-gray-200 dark:border-gray-700">
{!collapsed && ( {!collapsed && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="w-7 h-7 bg-gray-100 rounded flex items-center justify-center dark:bg-gray-800"> <div className="w-7 h-7 flex items-center justify-center">
<AppIcon className="w-5 h-5" /> <AppIcon className="w-5 h-5" />
</div> </div>
<span className="font-bold text-black text-sm dark:text-white">CLICD</span> <span className="font-bold text-black text-sm dark:text-white">CLICD</span>
</div> </div>
)} )}
{collapsed && ( {collapsed && (
<div className="w-7 h-7 bg-gray-100 rounded flex items-center justify-center mx-auto dark:bg-gray-800"> <div className="w-7 h-7 flex items-center justify-center mx-auto">
<AppIcon className="w-5 h-5" /> <AppIcon className="w-5 h-5" />
</div> </div>
)} )}
@@ -133,18 +150,6 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
{!isSubUser && ( {!isSubUser && (
<> <>
<button
onClick={() => navigate('/oversell')}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
isOversellPage
? 'bg-black text-white dark:bg-white dark:text-black'
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'
}`}
>
<Settings2 className="w-4 h-4" />
{!collapsed && <span>宿</span>}
</button>
<button <button
onClick={() => navigate('/security')} onClick={() => navigate('/security')}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${ className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
@@ -251,9 +256,29 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
{version && ( {version && (
<div className={`px-3 py-2 text-xs text-gray-400 dark:text-gray-500 ${collapsed ? 'text-center' : ''}`}> <div className={`px-3 py-2 text-xs text-gray-400 dark:text-gray-500 ${collapsed ? 'text-center' : ''}`}>
{collapsed ? ( {collapsed ? (
<span title={`v${version}`}>v{version.split('.').slice(0, 2).join('.')}</span> <a
href="https://github.com/MengMengCode/CLICD"
target="_blank"
rel="noreferrer"
title={`CLICD v${version}`}
className="inline-flex items-center justify-center rounded text-gray-400 transition-colors hover:text-gray-900 dark:text-gray-500 dark:hover:text-white"
>
<GitHubIcon className="h-4 w-4" />
</a>
) : ( ) : (
<span>v{version}</span> <div className="flex min-w-0 items-center gap-2">
<a
href="https://github.com/MengMengCode/CLICD"
target="_blank"
rel="noreferrer"
title="CLICD"
className="inline-flex min-w-0 items-center gap-1 rounded text-gray-500 transition-colors hover:text-gray-950 dark:text-gray-400 dark:hover:text-white"
>
<GitHubIcon className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">CLICD</span>
</a>
<span className="shrink-0">v{version}</span>
</div>
)} )}
</div> </div>
)} )}
+174
View File
@@ -0,0 +1,174 @@
import { useEffect, useRef, useState } from 'react'
import { Monitor, RefreshCw, Send, X } from 'lucide-react'
import RFB from '@novnc/novnc'
import { createVNCTicket, getWebVNCUrl } from '../services/api'
interface WebVNCViewerProps {
containerName: string
onClose: () => void
}
export default function WebVNCViewer({ containerName, onClose }: WebVNCViewerProps) {
const screenRef = useRef<HTMLDivElement>(null)
const rfbRef = useRef<RFB | null>(null)
const [status, setStatus] = useState<'connecting' | 'connected' | 'disconnected' | 'error'>('connecting')
const [errorMsg, setErrorMsg] = useState('')
const cleanup = () => {
if (rfbRef.current) {
rfbRef.current.disconnect()
rfbRef.current = null
}
}
const ensureResizeObserver = () => {
if ('ResizeObserver' in window) return
class FallbackResizeObserver {
private target: Element | null = null
private timer = 0
private lastWidth = -1
private lastHeight = -1
constructor(private callback: ResizeObserverCallback) {}
observe = (target: Element) => {
this.target = target
this.check()
this.timer = window.setInterval(this.check, 250)
window.addEventListener('resize', this.check)
}
unobserve = () => this.disconnect()
disconnect = () => {
if (this.timer) window.clearInterval(this.timer)
this.timer = 0
window.removeEventListener('resize', this.check)
this.target = null
}
private check = () => {
if (!this.target) return
const contentRect = this.target.getBoundingClientRect()
if (contentRect.width === this.lastWidth && contentRect.height === this.lastHeight) return
this.lastWidth = contentRect.width
this.lastHeight = contentRect.height
this.callback([{ target: this.target, contentRect } as ResizeObserverEntry], this as unknown as ResizeObserver)
}
}
;(window as unknown as { ResizeObserver: typeof ResizeObserver }).ResizeObserver = FallbackResizeObserver as unknown as typeof ResizeObserver
}
const connect = async () => {
const target = screenRef.current
if (!target) return
cleanup()
target.innerHTML = ''
setStatus('connecting')
setErrorMsg('')
let ticket = ''
try {
const response = await createVNCTicket(containerName)
ticket = response.data.data?.ticket || ''
} catch (err: unknown) {
const error = err as { response?: { data?: { message?: string } } }
setStatus('error')
setErrorMsg(error.response?.data?.message || 'WebVNC ticket 创建失败,请重新登录后再试')
return
}
if (!ticket) {
setStatus('error')
setErrorMsg('WebVNC ticket 为空,请重新登录后再试')
return
}
try {
ensureResizeObserver()
const rfb = new RFB(target, getWebVNCUrl(containerName), {
wsProtocols: ['binary', `clicd-vnc-ticket.${ticket}`],
})
rfb.scaleViewport = true
rfb.resizeSession = false
rfb.focusOnClick = true
rfb.qualityLevel = 6
rfb.compressionLevel = 2
rfb.background = '#050505'
rfb.addEventListener('connect', () => {
setStatus('connected')
})
rfb.addEventListener('disconnect', (event) => {
const detail = (event as CustomEvent<{ clean?: boolean }>).detail
setStatus((current) => current === 'error' ? current : 'disconnected')
if (detail && detail.clean === false) {
setErrorMsg('WebVNC 连接已断开,请确认虚拟机正在运行且 VNC 控制台可用')
}
})
rfb.addEventListener('securityfailure', () => {
setStatus('error')
setErrorMsg('VNC 安全协商失败')
})
rfb.addEventListener('credentialsrequired', () => {
setStatus('error')
setErrorMsg('当前 VNC 控制台要求密码,暂不支持自动输入')
})
rfbRef.current = rfb
} catch (err) {
console.error(err)
setStatus('error')
const message = err instanceof Error && err.message ? `${err.message}` : ''
setErrorMsg(`WebVNC 初始化失败${message}`)
}
}
useEffect(() => {
const timer = window.setTimeout(connect, 100)
return () => {
window.clearTimeout(timer)
cleanup()
}
}, [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 items-center gap-2">
<Monitor className="w-4 h-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>}
</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" />
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>
<button onClick={onClose} className="p-1.5 hover:bg-gray-200 rounded text-gray-500" title="关闭">
<X className="w-4 h-4" />
</button>
</div>
</div>
<div className="relative flex-1 min-h-0 bg-black overflow-hidden">
<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'}`}>
{status === 'connecting' ? '正在连接 KVM VNC 控制台...' : (errorMsg || 'WebVNC 已断开')}
</div>
)}
</div>
</div>
)
}
+14
View File
@@ -0,0 +1,14 @@
declare module '@novnc/novnc' {
export default class RFB extends EventTarget {
constructor(target: HTMLElement, url: string, options?: { credentials?: Record<string, string>; shared?: boolean; repeaterID?: string; wsProtocols?: string[] })
scaleViewport: boolean
resizeSession: boolean
focusOnClick: boolean
viewOnly: boolean
qualityLevel: number
compressionLevel: number
background: string
disconnect(): void
sendCtrlAltDel(): void
}
}
+1 -4
View File
@@ -248,10 +248,7 @@ export default function ApiIntegration() {
</section> </section>
<section> <section>
<h3 className="font-semibold text-black mb-2"> & </h3> <h3 className="font-semibold text-black mb-2"></h3>
<Endpoint method="POST" path="/api/oversell" desc="获取/更新超售配置" body='{"cpu_overcommit": 4, "ram_overcommit": 2, "disk_overcommit": 1, "ksm_enabled": true, "swappiness": 10}' />
<Endpoint method="POST" path="/api/oversell/reclaim" desc="触发一次内存回收" />
<Endpoint method="POST" path="/api/oversell/status" desc="超售状态" />
<Endpoint method="POST" path="/api/batch-create" desc="批量创建" body='{"containers": [{...}]}' /> <Endpoint method="POST" path="/api/batch-create" desc="批量创建" body='{"containers": [{...}]}' />
<Endpoint method="POST" path="/api/batch-action" desc="批量操作" body='{"action": "start", "containers": [1, 2, 3]}' /> <Endpoint method="POST" path="/api/batch-action" desc="批量操作" body='{"action": "start", "containers": [1, 2, 3]}' />
</section> </section>
+314 -60
View File
@@ -1,14 +1,18 @@
import { useState, useEffect, useCallback, type ReactNode } from 'react' import { useState, useEffect, useCallback, useRef, type ReactNode } from 'react'
import { useParams, useNavigate } from 'react-router-dom' import { useParams, useNavigate } from 'react-router-dom'
import { import {
ArrowLeft, ArrowLeft,
AlertTriangle,
Camera, Camera,
Clock, Clock,
Copy, Copy,
Cpu, Cpu,
HardDrive, HardDrive,
Key, Key,
Maximize2,
MemoryStick, MemoryStick,
Minimize2,
Monitor,
Network, Network,
Pencil, Pencil,
Play, Play,
@@ -65,6 +69,7 @@ import {
import { useDialog } from '../components/Dialog' import { useDialog } from '../components/Dialog'
import { useAuth } from '../contexts/AuthContext' import { useAuth } from '../contexts/AuthContext'
import WebSSHViewer from '../components/WebSSHViewer' import WebSSHViewer from '../components/WebSSHViewer'
import WebVNCViewer from '../components/WebVNCViewer'
import { RingStat } from '../components/RingStats' import { RingStat } from '../components/RingStats'
import { copyToClipboard } from '../utils/clipboard' import { copyToClipboard } from '../utils/clipboard'
import ResourceStatsPanel, { import ResourceStatsPanel, {
@@ -115,6 +120,9 @@ export default function ContainerDetail() {
const [actionLoading, setActionLoading] = useState<string | null>(null) const [actionLoading, setActionLoading] = useState<string | null>(null)
const [taskStatus, setTaskStatus] = useState('') // current task type for this container const [taskStatus, setTaskStatus] = useState('') // current task type for this container
const [showSSH, setShowSSH] = useState(false) const [showSSH, setShowSSH] = useState(false)
const [showVNC, setShowVNC] = useState(false)
const vncFullscreenRef = useRef<HTMLDivElement>(null)
const [vncFullscreen, setVncFullscreen] = useState(false)
const [showNat, setShowNat] = useState(false) const [showNat, setShowNat] = useState(false)
const [showNatAdd, setShowNatAdd] = useState(false) const [showNatAdd, setShowNatAdd] = useState(false)
const [showExpiryEdit, setShowExpiryEdit] = useState(false) const [showExpiryEdit, setShowExpiryEdit] = useState(false)
@@ -136,6 +144,10 @@ export default function ContainerDetail() {
const [resourceEdit, setResourceEdit] = useState({ vcpu: 1, ramMb: 512, ioMbps: 500, bwMbps: 100 }) const [resourceEdit, setResourceEdit] = useState({ vcpu: 1, ramMb: 512, ioMbps: 500, bwMbps: 100 })
const [savingResource, setSavingResource] = useState(false) const [savingResource, setSavingResource] = useState(false)
const [showPassword, setShowPassword] = useState(false) const [showPassword, setShowPassword] = useState(false)
const [showResetPassword, setShowResetPassword] = useState(false)
const [resetPasswordDraft, setResetPasswordDraft] = useState('')
const [resetPasswordResult, setResetPasswordResult] = useState('')
const [resetPasswordSaving, setResetPasswordSaving] = useState(false)
const [showSnapshots, setShowSnapshots] = useState(false) const [showSnapshots, setShowSnapshots] = useState(false)
const [snapshots, setSnapshots] = useState<Snapshot[]>([]) const [snapshots, setSnapshots] = useState<Snapshot[]>([])
const [snapshotQuota, setSnapshotQuota] = useState(3) const [snapshotQuota, setSnapshotQuota] = useState(3)
@@ -180,15 +192,18 @@ export default function ContainerDetail() {
const appendUsagePoint = useCallback((nextUsage: ContainerUsage, currentContainer: Container | null) => { const appendUsagePoint = useCallback((nextUsage: ContainerUsage, currentContainer: Container | null) => {
if (!containerIdentifier || !currentContainer) return if (!containerIdentifier || !currentContainer) return
const memoryPct = currentContainer.ram_mb > 0 const memoryTotalBytes = nextUsage.memory_total_bytes && nextUsage.memory_total_bytes > 0
? (nextUsage.memory_usage_bytes / (currentContainer.ram_mb * 1024 * 1024)) * 100 ? nextUsage.memory_total_bytes
: currentContainer.ram_mb * 1024 * 1024
const memoryPct = memoryTotalBytes > 0
? (nextUsage.memory_usage_bytes / memoryTotalBytes) * 100
: 0 : 0
const networkBps = (nextUsage.network_rx_bps || 0) + (nextUsage.network_tx_bps || 0) const networkBps = (nextUsage.network_rx_bps || 0) + (nextUsage.network_tx_bps || 0)
const diskIOBps = (nextUsage.disk_read_bps || 0) + (nextUsage.disk_write_bps || 0) const diskIOBps = (nextUsage.disk_read_bps || 0) + (nextUsage.disk_write_bps || 0)
const point: MetricPoint = { const point: MetricPoint = {
ts: Date.now(), ts: Date.now(),
cpu: clamp(nextUsage.cpu_usage_pct || 0), cpu: clamp((nextUsage.cpu_usage_pct || 0) / (currentContainer.vcpu || 1)),
memory: clamp(memoryPct), memory: clamp(memoryPct),
network: networkBps, network: networkBps,
diskIO: diskIOBps, diskIO: diskIOBps,
@@ -227,6 +242,28 @@ export default function ContainerDetail() {
return () => window.clearInterval(timer) return () => window.clearInterval(timer)
}, [fetchContainer]) }, [fetchContainer])
useEffect(() => {
const handleFullscreenChange = () => {
setVncFullscreen(document.fullscreenElement === vncFullscreenRef.current)
}
document.addEventListener('fullscreenchange', handleFullscreenChange)
return () => document.removeEventListener('fullscreenchange', handleFullscreenChange)
}, [])
const toggleVNCFullscreen = async () => {
const target = vncFullscreenRef.current
if (!target) return
try {
if (document.fullscreenElement === target) {
await document.exitFullscreen()
} else {
await target.requestFullscreen()
}
} catch {
setVncFullscreen((value) => !value)
}
}
useEffect(() => { useEffect(() => {
fetchUsage() fetchUsage()
const timer = window.setInterval(fetchUsage, 5000) const timer = window.setInterval(fetchUsage, 5000)
@@ -267,8 +304,17 @@ export default function ContainerDetail() {
start: '开机中...', stop: '关机中...', restart: '重启中...', delete: '删除中...', reinstall: '重装中...', start: '开机中...', stop: '关机中...', restart: '重启中...', delete: '删除中...', reinstall: '重装中...',
} }
const ensureSubUserCanOperate = async () => {
if (isSubUser && container?.policy_blocked) {
await dialog.alert('策略临时封禁', container.policy_blocked_reason || '虚拟机被策略临时封禁,暂不能执行操作。')
return false
}
return true
}
const handleAction = async (action: string) => { const handleAction = async (action: string) => {
if (!containerIdentifier) return if (!containerIdentifier) return
if (!(await ensureSubUserCanOperate())) return
setActionLoading(action) setActionLoading(action)
try { try {
switch (action) { switch (action) {
@@ -278,6 +324,7 @@ export default function ContainerDetail() {
case 'stop': case 'stop':
await stopContainer(containerIdentifier) await stopContainer(containerIdentifier)
setShowSSH(false) setShowSSH(false)
setShowVNC(false)
break break
case 'restart': case 'restart':
await restartContainer(containerIdentifier) await restartContainer(containerIdentifier)
@@ -359,7 +406,7 @@ export default function ContainerDetail() {
const openReinstall = async () => { const openReinstall = async () => {
try { try {
const res = await getEnabledImages() const res = await getEnabledImages(container?.virtualization || 'lxc')
if (res.data.data) { if (res.data.data) {
setTemplates(res.data.data) setTemplates(res.data.data)
setSelectedTemplate(res.data.data[0]?.id || '') setSelectedTemplate(res.data.data[0]?.id || '')
@@ -390,6 +437,7 @@ export default function ContainerDetail() {
await reinstallContainer(containerIdentifier, selectedTemplate) await reinstallContainer(containerIdentifier, selectedTemplate)
setShowReinstall(false) setShowReinstall(false)
setShowSSH(false) setShowSSH(false)
setShowVNC(false)
await fetchContainer() await fetchContainer()
} catch (err) { } catch (err) {
console.error('Reinstall failed:', err) console.error('Reinstall failed:', err)
@@ -399,20 +447,58 @@ export default function ContainerDetail() {
} }
} }
const generateResetPassword = () => {
const letters = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'
const digits = '23456789'
const symbols = '!@#$%*-_+='
const all = letters + digits + symbols
const pick = (chars: string) => chars[Math.floor(Math.random() * chars.length)]
let password = pick(letters) + pick(digits)
while (password.length < 16) password += pick(all)
setResetPasswordDraft(password.split('').sort(() => Math.random() - 0.5).join(''))
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 ''
}
const handleResetPassword = async () => { const handleResetPassword = async () => {
if (!containerIdentifier || !(await dialog.confirm('重置密码', `确定要重置容器 ${container?.name} 的 SSH 密码吗?`))) return if (!containerIdentifier) return
const password = resetPasswordDraft.trim()
const validationError = resetPasswordError(password)
if (validationError) {
await dialog.alert('密码格式不正确', validationError)
return
}
setResetPasswordSaving(true)
try { try {
const res = await resetSSHPassword(containerIdentifier) const res = await resetSSHPassword(containerIdentifier, password)
if (res.data.success) { if (res.data.success) {
await dialog.alert('密码已重置', `新密码: ${(res.data.data as { password: string })?.password}`) const nextPassword = (res.data.data as { password: string })?.password || password
setResetPasswordResult(nextPassword)
setResetPasswordDraft(nextPassword)
await fetchContainer() await fetchContainer()
} }
} catch (err) { } catch (err: unknown) {
console.error(err) console.error(err)
dialog.alert('密码重置失败', '请稍后重试') const error = err as { response?: { data?: { message?: string } } }
dialog.alert('密码重置失败', error.response?.data?.message || '请稍后重试')
} finally {
setResetPasswordSaving(false)
} }
} }
const openResetPassword = () => {
setResetPasswordDraft('')
setResetPasswordResult('')
setShowResetPassword(true)
}
const handleAssignIPv6 = async () => { const handleAssignIPv6 = async () => {
if (!containerIdentifier) return if (!containerIdentifier) return
setActionLoading('ipv6') setActionLoading('ipv6')
@@ -428,11 +514,13 @@ export default function ContainerDetail() {
} }
const openAddMapping = () => { const openAddMapping = () => {
if (isSubUser && container?.policy_blocked) return
setDraft(emptyDraft) setDraft(emptyDraft)
setShowNat(true) setShowNat(true)
} }
const openEditMapping = (pm: PortMapping, index: number) => { const openEditMapping = (pm: PortMapping, index: number) => {
if (isSubUser && container?.policy_blocked) return
if (isSubUser) { if (isSubUser) {
// Sub-user: only edit container_port in a simple modal // Sub-user: only edit container_port in a simple modal
setDraft({ setDraft({
@@ -455,6 +543,7 @@ export default function ContainerDetail() {
const submitMapping = async (): Promise<boolean> => { const submitMapping = async (): Promise<boolean> => {
if (!containerIdentifier) return false if (!containerIdentifier) return false
if (!(await ensureSubUserCanOperate())) return false
if (draft.index === null && container) { if (draft.index === null && container) {
const currentCount = container.port_mappings?.length || 0 const currentCount = container.port_mappings?.length || 0
const limit = container.port_mapping_limit || Math.max(currentCount, 2) const limit = container.port_mapping_limit || Math.max(currentCount, 2)
@@ -507,6 +596,7 @@ export default function ContainerDetail() {
const removeMapping = async (index: number) => { const removeMapping = async (index: number) => {
if (!containerIdentifier || !(await dialog.confirm('删除映射', '确定要删除这条映射规则吗?'))) return if (!containerIdentifier || !(await dialog.confirm('删除映射', '确定要删除这条映射规则吗?'))) return
if (!(await ensureSubUserCanOperate())) return
try { try {
await deletePortMapping(containerIdentifier, index) await deletePortMapping(containerIdentifier, index)
await fetchContainer() await fetchContainer()
@@ -519,6 +609,7 @@ export default function ContainerDetail() {
const handleCreateSnapshot = async () => { const handleCreateSnapshot = async () => {
if (!containerIdentifier) return if (!containerIdentifier) return
if (!(await ensureSubUserCanOperate())) return
if (isSubUser && snapshots.length >= snapshotQuota) { if (isSubUser && snapshots.length >= snapshotQuota) {
await dialog.alert('快照配额已满', '已达到管理员设置的快照配额,请先删除旧快照。') await dialog.alert('快照配额已满', '已达到管理员设置的快照配额,请先删除旧快照。')
return return
@@ -543,6 +634,7 @@ export default function ContainerDetail() {
} }
const openSnapshotSchedule = () => { const openSnapshotSchedule = () => {
if (isSubUser && container?.policy_blocked) return
setSnapshotScheduleDraft({ setSnapshotScheduleDraft({
intervalHours: Math.max(snapshotSchedule?.interval_hours || 24, 24), intervalHours: Math.max(snapshotSchedule?.interval_hours || 24, 24),
time: snapshotSchedule?.time || '03:00', time: snapshotSchedule?.time || '03:00',
@@ -552,6 +644,7 @@ export default function ContainerDetail() {
const saveSnapshotSchedule = async (enabled: boolean) => { const saveSnapshotSchedule = async (enabled: boolean) => {
if (!containerIdentifier) return if (!containerIdentifier) return
if (!(await ensureSubUserCanOperate())) return
const intervalHours = snapshotScheduleDraft.intervalHours const intervalHours = snapshotScheduleDraft.intervalHours
const scheduleTime = snapshotScheduleDraft.time || '03:00' const scheduleTime = snapshotScheduleDraft.time || '03:00'
if (enabled && intervalHours < 24) { if (enabled && intervalHours < 24) {
@@ -591,6 +684,7 @@ export default function ContainerDetail() {
const handleDeleteSnapshot = async (snapshot: Snapshot) => { const handleDeleteSnapshot = async (snapshot: Snapshot) => {
if (!containerIdentifier) return if (!containerIdentifier) return
if (!(await ensureSubUserCanOperate())) return
if (!(await dialog.confirm('删除快照', `确定删除 ${snapshot.created_at} 的快照吗?`))) return if (!(await dialog.confirm('删除快照', `确定删除 ${snapshot.created_at} 的快照吗?`))) return
setSnapshotBusy(snapshot.id) setSnapshotBusy(snapshot.id)
try { try {
@@ -606,6 +700,7 @@ export default function ContainerDetail() {
const handleRestoreSnapshot = async (snapshot: Snapshot) => { const handleRestoreSnapshot = async (snapshot: Snapshot) => {
if (!containerIdentifier) return if (!containerIdentifier) return
if (!(await ensureSubUserCanOperate())) return
if (!(await dialog.confirm('恢复快照', `确定恢复到 ${snapshot.created_at} 的快照吗?当前容器数据会被覆盖。`))) return if (!(await dialog.confirm('恢复快照', `确定恢复到 ${snapshot.created_at} 的快照吗?当前容器数据会被覆盖。`))) return
setSnapshotBusy(snapshot.id) setSnapshotBusy(snapshot.id)
try { try {
@@ -643,17 +738,28 @@ export default function ContainerDetail() {
} }
const isRunning = container.status === 'running' const isRunning = container.status === 'running'
const isKVM = (container.virtualization || 'lxc') === 'kvm'
const isWindows = container.template?.includes('windows')
const canOpenVNC = isKVM && isRunning
const isExpired = container.expires_at ? new Date(container.expires_at) < new Date() : false 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 publicHost = hostInfo?.network.public_ipv4 || PUBLIC_HOST
const maxVCPU = hostInfo?.cpu.cores || 64 const maxVCPU = hostInfo?.cpu.cores || 64
const maxRAMMB = hostInfo?.ram.total_mb ? Number(hostInfo.ram.total_mb) : undefined const maxRAMMB = hostInfo?.ram.total_mb ? Number(hostInfo.ram.total_mb) : undefined
const sshCommand = `ssh -p ${container.ssh_port} root@${publicHost}` const sshCommand = `ssh -p ${container.ssh_port} root@${publicHost}`
const editingSSH = draft.index !== null && !!container.port_mappings?.[draft.index] && ( 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 === 'SSH' || container.port_mappings[draft.index].container_port === 22 ||
container.port_mappings[draft.index].description === 'RDP' || container.port_mappings[draft.index].container_port === 3389
) )
const filtered = filterHistory(history, range) const filtered = filterHistory(history, range)
const cpuPct = clamp(usage?.cpu_usage_pct || 0) const cpuPct = clamp(((usage?.cpu_usage_pct || 0) / (container.vcpu || 1)))
const ramPct = container.ram_mb > 0 ? clamp(((usage?.memory_usage_bytes || 0) / (container.ram_mb * 1024 * 1024)) * 100) : 0 const ramTotalBytes = usage?.memory_total_bytes && usage.memory_total_bytes > 0
? usage.memory_total_bytes
: container.ram_mb * 1024 * 1024
const ramPct = ramTotalBytes > 0 ? clamp(((usage?.memory_usage_bytes || 0) / ramTotalBytes) * 100) : 0
const loadPct = container.vcpu > 0 ? ((usage?.load1 || 0) / container.vcpu) * 100 : 0
const diskPct = container.disk_gb > 0 ? clamp(((usage?.disk_usage_bytes || 0) / (container.disk_gb * 1024 * 1024 * 1024)) * 100) : 0 const diskPct = container.disk_gb > 0 ? clamp(((usage?.disk_usage_bytes || 0) / (container.disk_gb * 1024 * 1024 * 1024)) * 100) : 0
const networkBps = (usage?.network_rx_bps || 0) + (usage?.network_tx_bps || 0) const networkBps = (usage?.network_rx_bps || 0) + (usage?.network_tx_bps || 0)
const rx = usage?.network_rx_bps || 0 const rx = usage?.network_rx_bps || 0
@@ -661,7 +767,7 @@ export default function ContainerDetail() {
const diskIOBps = (usage?.disk_read_bps || 0) + (usage?.disk_write_bps || 0) const diskIOBps = (usage?.disk_read_bps || 0) + (usage?.disk_write_bps || 0)
const mappingCount = container.port_mappings?.length || 0 const mappingCount = container.port_mappings?.length || 0
const mappingLimit = container.port_mapping_limit || Math.max(mappingCount, 2) const mappingLimit = container.port_mapping_limit || Math.max(mappingCount, 2)
const canAddMapping = isSubUser ? mappingCount < mappingLimit : true const canAddMapping = isSubUser ? mappingCount < mappingLimit && !isSubUserPolicyBlocked : true
const managementUrl = subUser?.access_code const managementUrl = subUser?.access_code
? `${window.location.origin}/login?code=${encodeURIComponent(subUser.access_code)}` ? `${window.location.origin}/login?code=${encodeURIComponent(subUser.access_code)}`
: '' : ''
@@ -669,7 +775,7 @@ export default function ContainerDetail() {
{ {
title: 'CPU 使用率', title: 'CPU 使用率',
icon: <Cpu className="w-5 h-5" />, icon: <Cpu className="w-5 h-5" />,
current: usage?.cpu_usage_pct || 0, current: clamp(((usage?.cpu_usage_pct || 0) / (container.vcpu || 1))),
points: toChartPoints(filtered, 'cpu'), points: toChartPoints(filtered, 'cpu'),
max: 100, max: 100,
formatValue: formatPercent, formatValue: formatPercent,
@@ -682,7 +788,7 @@ export default function ContainerDetail() {
points: toChartPoints(filtered, 'memory'), points: toChartPoints(filtered, 'memory'),
max: 100, max: 100,
formatValue: formatPercent, formatValue: formatPercent,
detail: `${formatBytes(usage?.memory_usage_bytes || 0)} / ${container.ram_mb} MB`, detail: `${formatBytes(usage?.memory_usage_bytes || 0)} / ${formatBytes(ramTotalBytes)}`,
}, },
{ {
title: '网络流量', title: '网络流量',
@@ -718,7 +824,7 @@ export default function ContainerDetail() {
<div className="bg-white border border-gray-200 rounded-lg p-5"> <div className="bg-white border border-gray-200 rounded-lg p-5">
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-4"> <div className="flex items-start gap-4">
<div className="w-14 h-14 bg-slate-100 rounded-lg flex items-center justify-center"> <div className="w-14 h-14 flex items-center justify-center">
{getTemplateIcon(container.template || '') || <Cpu className="w-7 h-7 text-slate-700" />} {getTemplateIcon(container.template || '') || <Cpu className="w-7 h-7 text-slate-700" />}
</div> </div>
<div> <div>
@@ -728,33 +834,43 @@ export default function ContainerDetail() {
</div> </div>
<div className="flex items-center gap-2 flex-wrap mt-2"> <div className="flex items-center gap-2 flex-wrap mt-2">
<InfoTag color="blue"> {container.template}</InfoTag> <InfoTag color="blue"> {container.template}</InfoTag>
<InfoTag color="slate"> {(container.virtualization || 'lxc').toUpperCase()}</InfoTag>
<InfoTag color="emerald"> {container.ip || '-'}</InfoTag> <InfoTag color="emerald"> {container.ip || '-'}</InfoTag>
<InfoTag color="amber">NAT {mappingCount} </InfoTag> <InfoTag color="amber">NAT {mappingCount} </InfoTag>
<InfoTag color="violet">{publicHost}:{container.ssh_port}</InfoTag> <InfoTag color="violet">{isWindows ? 'RDP' : 'SSH'} {publicHost}:{container.ssh_port}</InfoTag>
{isPolicyBlocked && <InfoTag color="red"></InfoTag>}
</div> </div>
</div> </div>
</div> </div>
<div className="flex items-center gap-1.5 flex-wrap justify-end"> <div className="flex items-center gap-1.5 flex-wrap justify-end">
{!isRunning ? ( {!isRunning ? (
<ActionButton dark disabled={!!taskStatus || isExpired} onClick={() => handleAction('start')}> <ActionButton dark disabled={!!taskStatus || isExpired || isSubUserPolicyBlocked} onClick={() => handleAction('start')}>
<Play className="w-3.5 h-3.5" /> <Play className="w-3.5 h-3.5" />
{isExpired ? '已到期' : taskStatus === 'start' ? taskActionLabels['start'] : '开机'} {isSubUserPolicyBlocked ? '已封禁' : isExpired ? '已到期' : taskStatus === 'start' ? taskActionLabels['start'] : '开机'}
</ActionButton> </ActionButton>
) : ( ) : (
<> <>
<ActionButton disabled={!!taskStatus || isExpired} onClick={() => handleAction('stop')}> <ActionButton disabled={!!taskStatus || isExpired || isSubUserPolicyBlocked} onClick={() => handleAction('stop')}>
<Square className="w-3.5 h-3.5" /> <Square className="w-3.5 h-3.5" />
{isExpired ? '已到期' : taskStatus === 'stop' ? taskActionLabels['stop'] : '关机'} {isSubUserPolicyBlocked ? '已封禁' : isExpired ? '已到期' : taskStatus === 'stop' ? taskActionLabels['stop'] : '关机'}
</ActionButton> </ActionButton>
<ActionButton disabled={!!taskStatus || isExpired} onClick={() => handleAction('restart')}> <ActionButton disabled={!!taskStatus || isExpired || isSubUserPolicyBlocked} onClick={() => handleAction('restart')}>
<RefreshCw className="w-3.5 h-3.5" /> <RefreshCw className="w-3.5 h-3.5" />
{isExpired ? '已到期' : taskStatus === 'restart' ? taskActionLabels['restart'] : '重启'} {isSubUserPolicyBlocked ? '已封禁' : isExpired ? '已到期' : taskStatus === 'restart' ? taskActionLabels['restart'] : '重启'}
</ActionButton>
<ActionButton dark onClick={() => setShowSSH(true)}>
<TerminalSquare className="w-3.5 h-3.5" />
WebSSH
</ActionButton> </ActionButton>
{!isWindows && (
<ActionButton dark disabled={isSubUserPolicyBlocked} onClick={() => setShowSSH(true)}>
<TerminalSquare className="w-3.5 h-3.5" />
WebSSH
</ActionButton>
)}
{isKVM && (
<ActionButton dark disabled={!canOpenVNC || isSubUserPolicyBlocked} onClick={() => setShowVNC(true)}>
<Monitor className="w-3.5 h-3.5" />
WebVNC
</ActionButton>
)}
</> </>
)} )}
{!isSubUser && ( {!isSubUser && (
@@ -764,12 +880,12 @@ export default function ContainerDetail() {
</ActionButton> </ActionButton>
)} )}
<> <>
<ActionButton onClick={() => setShowNat(true)}> <ActionButton disabled={isSubUserPolicyBlocked} onClick={() => setShowNat(true)}>
<Settings className="w-3.5 h-3.5" /> <Settings className="w-3.5 h-3.5" />
NAT NAT
</ActionButton> </ActionButton>
</> </>
<ActionButton onClick={() => setShowSnapshots(true)} disabled={!!taskStatus || !!snapshotBusy}> <ActionButton onClick={() => setShowSnapshots(true)} disabled={!!taskStatus || !!snapshotBusy || isSubUserPolicyBlocked}>
<Camera className="w-3.5 h-3.5" /> <Camera className="w-3.5 h-3.5" />
</ActionButton> </ActionButton>
@@ -789,32 +905,80 @@ export default function ContainerDetail() {
</div> </div>
</div> </div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-5"> {isSubUserPolicyBlocked && (
<Panel title="连接信息"> <div className="flex items-start gap-3 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
<PlainRow label="SSH 地址" value={`${publicHost}:${container.ssh_port}`} mono copyValue={sshCommand} onCopy={copyText} /> <AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<PlainRow label="用户名" value="root" mono /> <div>
<div className="flex items-center justify-between gap-3"> <div className="font-medium"></div>
<span className="text-gray-500">SSH </span> <div className="mt-1 text-xs text-red-600">{policyBlockedText}</div>
<div className="flex items-center gap-1 min-w-0">
<span
className={`font-mono text-xs cursor-pointer select-none ${showPassword ? 'text-black' : 'text-gray-400 tracking-[0.25em]'}`}
onClick={() => setShowPassword(!showPassword)}
title={showPassword ? '点击隐藏' : '点击显示'}
>
{showPassword ? (container.ssh_password || '-') : '••••••••'}
</span>
{container.ssh_password && (
<button onClick={() => copyText(container.ssh_password)} className="p-0.5 text-gray-400 hover:text-black rounded" title="复制">
<Copy className="w-3 h-3" />
</button>
)}
</div>
</div> </div>
{!isSubUser && ( </div>
<button onClick={handleResetPassword} className="inline-flex items-center gap-1.5 text-xs text-gray-600 hover:text-black"> )}
<Key className="w-3 h-3" />
<div className="grid grid-cols-1 lg:grid-cols-3 gap-5">
<Panel
title="连接信息"
extra={!isSubUser && !isWindows && !isSubUserPolicyBlocked ? (
<button
onClick={openResetPassword}
className="inline-flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs text-gray-600 hover:bg-gray-100 hover:text-black"
>
<Key className="w-3.5 h-3.5" />
SSH SSH
</button> </button>
) : undefined}
>
{isSubUserPolicyBlocked ? (
<div className="rounded-md border border-red-100 bg-red-50 px-3 py-2 text-sm text-red-700">
</div>
) : isWindows ? (
<>
<PlainRow label="RDP 地址" value={`${publicHost}:${container.ssh_port}`} mono />
<PlainRow label="用户名" value="Administrator" mono />
<div className="flex items-center justify-between gap-3">
<span className="text-gray-500"></span>
<div className="flex items-center gap-1 min-w-0">
<span
className={`font-mono text-xs cursor-pointer select-none ${showPassword ? 'text-black' : 'text-gray-400 tracking-[0.25em]'}`}
onClick={() => setShowPassword(!showPassword)}
title={showPassword ? '点击隐藏' : '点击显示'}
>
{container.ssh_password ? (showPassword ? container.ssh_password : '••••••••') : '-'}
</span>
{container.ssh_password && (
<button onClick={() => copyText(container.ssh_password)} className="p-0.5 text-gray-400 hover:text-black rounded" title="复制">
<Copy className="w-3 h-3" />
</button>
)}
</div>
</div>
{container.vnc_port > 0 && (
<PlainRow label="VNC 端口" value={`127.0.0.1:${container.vnc_port}`} mono />
)}
</>
) : (
<>
<PlainRow label="SSH 地址" value={`${publicHost}:${container.ssh_port}`} 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>
<div className="flex items-center gap-1 min-w-0">
<span
className={`font-mono text-xs cursor-pointer select-none ${showPassword ? 'text-black' : 'text-gray-400 tracking-[0.25em]'}`}
onClick={() => setShowPassword(!showPassword)}
title={showPassword ? '点击隐藏' : '点击显示'}
>
{container.ssh_password ? (showPassword ? container.ssh_password : '••••••••') : '-'}
</span>
{container.ssh_password && (
<button onClick={() => copyText(container.ssh_password)} className="p-0.5 text-gray-400 hover:text-black rounded" title="复制">
<Copy className="w-3 h-3" />
</button>
)}
</div>
</div>
</>
)} )}
</Panel> </Panel>
@@ -869,12 +1033,12 @@ export default function ContainerDetail() {
<RingStat <RingStat
value={ramPct} value={ramPct}
label="内存" label="内存"
subLabel={`${formatMB(usage?.memory_usage_bytes || 0)} / ${formatMB(container.ram_mb * 1024 * 1024)}`} subLabel={`${formatMB(usage?.memory_usage_bytes || 0)} / ${formatMB(ramTotalBytes)}`}
/> />
<RingStat <RingStat
value={Math.min(cpuPct, 100)} value={loadPct}
max={Infinity}
label="负载" label="负载"
subLabel={cpuPct < 70 ? '正常' : cpuPct < 90 ? '中等' : '高'}
/> />
<RingStat <RingStat
value={diskPct} value={diskPct}
@@ -966,6 +1130,60 @@ export default function ContainerDetail() {
<ResourceStatsPanel range={range} onRangeChange={setRange} onRefresh={() => { fetchContainer(); fetchUsage() }} charts={charts} /> <ResourceStatsPanel range={range} onRangeChange={setRange} onRefresh={() => { fetchContainer(); fetchUsage() }} charts={charts} />
{showResetPassword && (
<Modal title="重置 SSH 密码" onClose={() => setShowResetPassword(false)}>
<div className="space-y-4">
<div>
<label className="block text-xs text-gray-500 mb-1"> SSH </label>
<div className="flex gap-2">
<input
type="text"
value={resetPasswordDraft}
onChange={(e) => { setResetPasswordDraft(e.target.value); setResetPasswordResult('') }}
placeholder="请输入 8-64 位,至少包含字母和数字"
className={inputClass}
/>
<button
type="button"
onClick={generateResetPassword}
className="px-3 py-2 border border-gray-300 rounded-md text-gray-600 hover:bg-gray-50 hover:text-black"
title="生成随机密码"
>
<RefreshCw className="w-4 h-4" />
</button>
</div>
{resetPasswordDraft && resetPasswordError(resetPasswordDraft) && (
<p className="mt-1 text-xs text-red-600">{resetPasswordError(resetPasswordDraft)}</p>
)}
</div>
{resetPasswordResult && (
<div className="p-3 bg-green-50 border border-green-200 rounded-md">
<div className="text-xs text-green-700 mb-1"></div>
<div className="flex items-center justify-between gap-2">
<span className="font-mono text-sm text-green-900 break-all">{resetPasswordResult}</span>
<button onClick={() => copyText(resetPasswordResult)} className="p-1 text-green-700 hover:text-green-900 rounded" title="复制">
<Copy className="w-4 h-4" />
</button>
</div>
</div>
)}
<p className="text-xs text-gray-500 leading-relaxed">
Linux LXC/KVM root SSH KVM guest agent SSH
</p>
<div className="flex justify-end gap-2 pt-2">
<button onClick={() => setShowResetPassword(false)} className="px-4 py-2 text-sm text-gray-600 border border-gray-200 rounded-md hover:bg-gray-50"></button>
<button
onClick={handleResetPassword}
disabled={resetPasswordSaving || !resetPasswordDraft || !!resetPasswordError(resetPasswordDraft)}
className="px-4 py-2 text-sm bg-black text-white rounded-md hover:bg-gray-800 disabled:opacity-50"
>
{resetPasswordSaving ? '修改中...' : '确认修改'}
</button>
</div>
</div>
</Modal>
)}
{showSSH && ( {showSSH && (
<Modal title={`WebSSH - ${container.name}`} onClose={() => setShowSSH(false)} wide> <Modal title={`WebSSH - ${container.name}`} onClose={() => setShowSSH(false)} wide>
<div className="h-[70vh] min-h-[520px]"> <div className="h-[70vh] min-h-[520px]">
@@ -978,6 +1196,38 @@ export default function ContainerDetail() {
</Modal> </Modal>
)} )}
{showVNC && (
<Modal
title={`WebVNC - ${container.name}`}
onClose={() => setShowVNC(false)}
wide
flush
extra={
<button
onClick={toggleVNCFullscreen}
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 text-xs text-gray-600 hover:text-black hover:bg-gray-100 rounded"
title={vncFullscreen ? '退出全屏' : '全屏显示'}
>
{vncFullscreen ? <Minimize2 className="w-3.5 h-3.5" /> : <Maximize2 className="w-3.5 h-3.5" />}
{vncFullscreen ? '退出全屏' : '全屏'}
</button>
}
>
<div
ref={vncFullscreenRef}
className={`bg-white ${vncFullscreen ? 'fixed inset-0 z-[70] p-3' : 'h-[calc(92vh-112px)] min-h-[420px] p-5'}`}
>
<div className="h-full">
{canOpenVNC ? (
<WebVNCViewer containerName={container.name} onClose={() => setShowVNC(false)} />
) : (
<div className="h-full flex items-center justify-center bg-gray-950 text-gray-400 rounded-md">VNC KVM </div>
)}
</div>
</div>
</Modal>
)}
{showSnapshots && ( {showSnapshots && (
<Modal <Modal
title="快照" title="快照"
@@ -1395,12 +1645,14 @@ function StatusBadge({ running }: { running: boolean }) {
) )
} }
function InfoTag({ color, children }: { color: 'blue' | 'emerald' | 'amber' | 'violet'; children: ReactNode }) { function InfoTag({ color, children }: { color: 'blue' | 'emerald' | 'amber' | 'violet' | 'slate' | 'red'; children: ReactNode }) {
const classes = { const classes = {
blue: 'bg-blue-50 text-blue-700 border-blue-100', blue: 'bg-blue-50 text-blue-700 border-blue-100',
emerald: 'bg-emerald-50 text-emerald-700 border-emerald-100', emerald: 'bg-emerald-50 text-emerald-700 border-emerald-100',
amber: 'bg-amber-50 text-amber-700 border-amber-100', amber: 'bg-amber-50 text-amber-700 border-amber-100',
violet: 'bg-violet-50 text-violet-700 border-violet-100', violet: 'bg-violet-50 text-violet-700 border-violet-100',
slate: 'bg-slate-50 text-slate-700 border-slate-100',
red: 'bg-red-50 text-red-700 border-red-100',
} }
return <span className={`px-1.5 py-0.5 border rounded text-[11px] whitespace-nowrap ${classes[color]}`}>{children}</span> return <span className={`px-1.5 py-0.5 border rounded text-[11px] whitespace-nowrap ${classes[color]}`}>{children}</span>
} }
@@ -1570,7 +1822,7 @@ function MappingTable({ mappings, publicHost, onEdit, onDelete, compact = false,
</thead> </thead>
<tbody className="divide-y divide-gray-100"> <tbody className="divide-y divide-gray-100">
{mappings.map((pm, index) => { {mappings.map((pm, index) => {
const isSSH = pm.description === 'SSH' || pm.container_port === 22 const isSSH = pm.description === 'SSH' || pm.container_port === 22 || pm.description === 'RDP' || pm.container_port === 3389
return ( return (
<tr key={`${pm.host_port}-${pm.container_port}-${index}`}> <tr key={`${pm.host_port}-${pm.container_port}-${index}`}>
<td className="px-3 py-2 text-sm"> <td className="px-3 py-2 text-sm">
@@ -1616,7 +1868,7 @@ function Field({ label, children }: { label: string; children: ReactNode }) {
) )
} }
function Modal({ title, children, onClose, wide = false, extra }: { title: string; children: ReactNode; onClose: () => void; wide?: boolean; extra?: ReactNode }) { function Modal({ title, children, onClose, wide = false, extra, flush = false }: { title: string; children: ReactNode; onClose: () => void; wide?: boolean; extra?: ReactNode; flush?: boolean }) {
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className={`bg-white rounded-lg shadow-xl border border-gray-200 w-full ${wide ? 'max-w-5xl' : 'max-w-md'} max-h-[92vh] overflow-hidden flex flex-col`}> <div className={`bg-white rounded-lg shadow-xl border border-gray-200 w-full ${wide ? 'max-w-5xl' : 'max-w-md'} max-h-[92vh] overflow-hidden flex flex-col`}>
@@ -1629,7 +1881,7 @@ function Modal({ title, children, onClose, wide = false, extra }: { title: strin
</button> </button>
</div> </div>
</div> </div>
<div className="p-5 overflow-y-auto">{children}</div> <div className={flush ? "overflow-hidden" : "p-5 overflow-y-auto"}>{children}</div>
</div> </div>
</div> </div>
) )
@@ -1804,6 +2056,7 @@ function TrafficBar({ container }: { container: Container }) {
function getTemplateIcon(id: string): ReactNode { function getTemplateIcon(id: string): ReactNode {
const size = 'w-6 h-6' const size = 'w-6 h-6'
id = id.startsWith('kvm-') ? id.slice(4) : id
if (id.startsWith('debian')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M935.473 375.359a558.602 558.602 0 0 0-22.351-114.655l13.308 4.436c-35.66-81.385-90.086-163.623-153.556-199.282-8.701-5.118-35.147 4.948-26.616-12.113s-37.536-8.19-56.816-4.778c-26.275 4.266-30.028-29.175-75.071-35.83-25.593-3.582-32.247 18.427-44.702 13.309-23.545-9.384-20.816-27.64-57.669-9.384-18.427 9.042 11.602-26.105-49.138-4.607L457.744 0C349.23 41.63 318.69 76.266 288.15 79.337c-6.996 0-34.124 32.759-53.574 53.062-17.062 17.062-26.275 36.512-49.138 39.583l-17.062 70.636A136.494 136.494 0 0 0 119.41 339.7a66.711 66.711 0 0 1 4.436-52.892c-17.062 6.825-45.896 17.062-29.687 96.91 12.796 63.13-5.29 135.13 10.066 204.742 4.777 20.986 0 40.095 6.142 51.185 107.66 235.794 208.836 392.08 472.44 384.06l4.436-8.872c-28.152-6.825-55.11-17.062-111.584-30.711-18.597-4.436-23.033-34.124-40.265-44.19-9.384-5.46-28.323-4.095-37.195-9.896s4.266-21.668-19.962-14.332c-8.531 2.56-13.82-10.92-20.133-17.061s0-23.716-23.375-24.74-18.426-29.687-19.791-44.702c-12.114 1.536-1.195-1.535-13.308 4.436a63.64 63.64 0 0 1-23.887-31.735c-10.237-48.967-10.578-21.497-15.014-32.417a322.297 322.297 0 0 0-19.28-42.142l26.787 8.872h4.436l4.436-13.309-26.616-8.701h31.223c-7.678 13.99 2.047 5.29-13.479 8.872v13.308l22.35-8.872v-13.308c-20.644-10.237-28.663-13.308-49.137-22.01l9.043 8.872v4.436h-49.138c-22.01-14.843-13.99-31.734-17.915-53.062 17.062 0 9.213 6.655 17.062-13.137l-17.062 8.872 13.308-33.953-13.308 13.138c-29.176-38.73-16.209-97.764-11.943-152.02A180.684 180.684 0 0 1 211.2 372.97c8.872-10.067 5.119-25.251 5.46-37.195l31.223-26.445H265.8c7.678 17.061 4.777 5.46 0 22.01l8.872 4.435c7.166-8.701 6.142-5.971 8.872-22.01-10.066-10.578-6.995-9.895-26.616-13.308A119.432 119.432 0 0 1 368.51 243.13l4.436-13.308-17.915 9.043-4.436-13.138a109.536 109.536 0 0 1 76.095-27.128c6.313 0 6.996-17.062 12.797-19.45 161.574-60.57 309.33 9.383 371.093 147.413a324.173 324.173 0 0 1 8.19 34.123c17.061 56.987-7.167 121.48 9.725 155.604-7.849 36-36.683 13.82-40.266 30.881-8.531 41.29-14.844 59.717-40.778 78.826a196.38 196.38 0 0 1-30.711 22.35 84.285 84.285 0 0 0 22.35-39.753c-106.294 111.584-262.58 63.981-290.049-105.954a101.176 101.176 0 0 1 35.147-93.157c92.987-87.527 150.144-52.38 205.765-20.474l-8.872-30.711c-32.93-24.398-17.062-19.792-9.043-57.328v-4.436l-17.915-13.137c2.56 10.066 1.024 5.289 9.043 17.061-4.436 16.039 0 9.043-8.872 17.062-15.014 9.725-23.716 7.337-44.702 4.436l4.436-13.308-13.308-13.308c0 11.773-4.095 2.73 0 17.062-126.086 9.896-218.05 80.02-178.636 260.191a220.608 220.608 0 0 0 8.872 44.19l-8.872 8.702-4.436-26.446h-13.48l-4.435 13.308c-12.626-25.763-0.853 10.75 40.265 52.892a149.29 149.29 0 0 0 12.797 12.625c47.773 34.124 113.29 81.385 201.328 49.138h9.043v-4.436l-102.37-13.308-4.436-8.701c106.806 24.74 176.93-8.531 236.646-48.456 13.138-17.062 11.431-24.057 22.18-9.043 19.28-17.061 3.925-26.786 13.48-44.019 6.483-11.772 32.587-17.062 44.7-35.318l40.096-136.494h-17.062c3.071-14.332 22.522-34.123-4.436-48.455-2.559-1.536 9.043-1.365 8.872-4.266a145.537 145.537 0 0 0-22.18-66.37c33.1 21.669 36.342 68.247 53.574 105.783v8.872h4.436V375.36zM453.308 595.455l-9.555-26.446 62.446 57.328zM146.196 211.736l-23.204-4.436v39.754c16.72-10.578 18.939-10.407 22.35-35.318z m574.981 176.419a57.498 57.498 0 0 0-17.062 44.19l13.48 8.701a37.877 37.877 0 0 0 4.435-52.891zM868.42 555.872c26.275-11.602 54.598-58.01 35.83-97.081l-35.83 96.91z m-174.03-79.508c-15.697 11.773-19.791 13.308-22.35 39.754l13.307 8.872 17.915-8.872a60.228 60.228 0 0 0 4.436-48.455c-8.36 13.478-2.559 20.644-13.308 8.701z m-67.053 79.508c15.868-10.92 11.944-14.844 17.915-22.18v-4.778a292.097 292.097 0 0 1-62.446 0c-13.137-13.99-13.308-29.346-31.223-39.583 17.062 35.147 3.242 38.218 31.223 61.764a158.162 158.162 0 0 0 40.095 4.436c1.536 0-6.824-1.024 4.436 0zM207.79 520.554H194.31l-8.872 8.702c9.555 10.237 5.46 7.166 13.308-4.436L212.225 547l4.436-17.062-8.872-8.701z m17.062 57.328l4.436-8.873c-10.067-8.701 0-3.583-13.308 0l-13.309-17.061 4.436 17.061v8.873h17.062z" fill="#CE0C48"/></svg> if (id.startsWith('debian')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M935.473 375.359a558.602 558.602 0 0 0-22.351-114.655l13.308 4.436c-35.66-81.385-90.086-163.623-153.556-199.282-8.701-5.118-35.147 4.948-26.616-12.113s-37.536-8.19-56.816-4.778c-26.275 4.266-30.028-29.175-75.071-35.83-25.593-3.582-32.247 18.427-44.702 13.309-23.545-9.384-20.816-27.64-57.669-9.384-18.427 9.042 11.602-26.105-49.138-4.607L457.744 0C349.23 41.63 318.69 76.266 288.15 79.337c-6.996 0-34.124 32.759-53.574 53.062-17.062 17.062-26.275 36.512-49.138 39.583l-17.062 70.636A136.494 136.494 0 0 0 119.41 339.7a66.711 66.711 0 0 1 4.436-52.892c-17.062 6.825-45.896 17.062-29.687 96.91 12.796 63.13-5.29 135.13 10.066 204.742 4.777 20.986 0 40.095 6.142 51.185 107.66 235.794 208.836 392.08 472.44 384.06l4.436-8.872c-28.152-6.825-55.11-17.062-111.584-30.711-18.597-4.436-23.033-34.124-40.265-44.19-9.384-5.46-28.323-4.095-37.195-9.896s4.266-21.668-19.962-14.332c-8.531 2.56-13.82-10.92-20.133-17.061s0-23.716-23.375-24.74-18.426-29.687-19.791-44.702c-12.114 1.536-1.195-1.535-13.308 4.436a63.64 63.64 0 0 1-23.887-31.735c-10.237-48.967-10.578-21.497-15.014-32.417a322.297 322.297 0 0 0-19.28-42.142l26.787 8.872h4.436l4.436-13.309-26.616-8.701h31.223c-7.678 13.99 2.047 5.29-13.479 8.872v13.308l22.35-8.872v-13.308c-20.644-10.237-28.663-13.308-49.137-22.01l9.043 8.872v4.436h-49.138c-22.01-14.843-13.99-31.734-17.915-53.062 17.062 0 9.213 6.655 17.062-13.137l-17.062 8.872 13.308-33.953-13.308 13.138c-29.176-38.73-16.209-97.764-11.943-152.02A180.684 180.684 0 0 1 211.2 372.97c8.872-10.067 5.119-25.251 5.46-37.195l31.223-26.445H265.8c7.678 17.061 4.777 5.46 0 22.01l8.872 4.435c7.166-8.701 6.142-5.971 8.872-22.01-10.066-10.578-6.995-9.895-26.616-13.308A119.432 119.432 0 0 1 368.51 243.13l4.436-13.308-17.915 9.043-4.436-13.138a109.536 109.536 0 0 1 76.095-27.128c6.313 0 6.996-17.062 12.797-19.45 161.574-60.57 309.33 9.383 371.093 147.413a324.173 324.173 0 0 1 8.19 34.123c17.061 56.987-7.167 121.48 9.725 155.604-7.849 36-36.683 13.82-40.266 30.881-8.531 41.29-14.844 59.717-40.778 78.826a196.38 196.38 0 0 1-30.711 22.35 84.285 84.285 0 0 0 22.35-39.753c-106.294 111.584-262.58 63.981-290.049-105.954a101.176 101.176 0 0 1 35.147-93.157c92.987-87.527 150.144-52.38 205.765-20.474l-8.872-30.711c-32.93-24.398-17.062-19.792-9.043-57.328v-4.436l-17.915-13.137c2.56 10.066 1.024 5.289 9.043 17.061-4.436 16.039 0 9.043-8.872 17.062-15.014 9.725-23.716 7.337-44.702 4.436l4.436-13.308-13.308-13.308c0 11.773-4.095 2.73 0 17.062-126.086 9.896-218.05 80.02-178.636 260.191a220.608 220.608 0 0 0 8.872 44.19l-8.872 8.702-4.436-26.446h-13.48l-4.435 13.308c-12.626-25.763-0.853 10.75 40.265 52.892a149.29 149.29 0 0 0 12.797 12.625c47.773 34.124 113.29 81.385 201.328 49.138h9.043v-4.436l-102.37-13.308-4.436-8.701c106.806 24.74 176.93-8.531 236.646-48.456 13.138-17.062 11.431-24.057 22.18-9.043 19.28-17.061 3.925-26.786 13.48-44.019 6.483-11.772 32.587-17.062 44.7-35.318l40.096-136.494h-17.062c3.071-14.332 22.522-34.123-4.436-48.455-2.559-1.536 9.043-1.365 8.872-4.266a145.537 145.537 0 0 0-22.18-66.37c33.1 21.669 36.342 68.247 53.574 105.783v8.872h4.436V375.36zM453.308 595.455l-9.555-26.446 62.446 57.328zM146.196 211.736l-23.204-4.436v39.754c16.72-10.578 18.939-10.407 22.35-35.318z m574.981 176.419a57.498 57.498 0 0 0-17.062 44.19l13.48 8.701a37.877 37.877 0 0 0 4.435-52.891zM868.42 555.872c26.275-11.602 54.598-58.01 35.83-97.081l-35.83 96.91z m-174.03-79.508c-15.697 11.773-19.791 13.308-22.35 39.754l13.307 8.872 17.915-8.872a60.228 60.228 0 0 0 4.436-48.455c-8.36 13.478-2.559 20.644-13.308 8.701z m-67.053 79.508c15.868-10.92 11.944-14.844 17.915-22.18v-4.778a292.097 292.097 0 0 1-62.446 0c-13.137-13.99-13.308-29.346-31.223-39.583 17.062 35.147 3.242 38.218 31.223 61.764a158.162 158.162 0 0 0 40.095 4.436c1.536 0-6.824-1.024 4.436 0zM207.79 520.554H194.31l-8.872 8.702c9.555 10.237 5.46 7.166 13.308-4.436L212.225 547l4.436-17.062-8.872-8.701z m17.062 57.328l4.436-8.873c-10.067-8.701 0-3.583-13.308 0l-13.309-17.061 4.436 17.061v8.873h17.062z" fill="#CE0C48"/></svg>
if (id.startsWith('ubuntu')) return <svg className={size} viewBox="0 0 1024 1024"><circle cx="512" cy="512" r="511" fill="#DD4814"/><path d="M164.532 442.532c-37.676 0-68.2 30.524-68.2 68.2 0 37.656 30.524 68.184 68.2 68.184 37.66 0 68.184-30.528 68.184-68.184 0-37.676-30.524-68.2-68.184-68.2z m486.86 309.912c-32.612 18.84-43.8 60.52-24.96 93.116 18.82 32.616 60.5 43.796 93.116 24.96 32.612-18.82 43.796-60.5 24.96-93.12-18.82-32.592-60.524-43.772-93.116-24.956z m-338.744-241.712c0-67.384 33.472-126.92 84.684-162.968L347.48 264.268c-59.656 39.88-104.048 100.816-122.496 172.188 21.528 17.56 35.304 44.3 35.304 74.272 0 29.956-13.776 56.696-35.304 74.26C243.408 656.376 287.8 717.32 347.48 757.2l49.852-83.52c-51.212-36.028-84.684-95.56-84.684-162.948z m199.168-199.188c104.052 0 189.42 79.776 198.38 181.52l97.16-1.432c-4.776-75.112-37.592-142.544-88.008-192.128-25.928 9.796-55.88 8.296-81.76-6.624-25.932-14.964-42.192-40.208-46.636-67.608a297.04 297.04 0 0 0-79.14-10.76 295.148 295.148 0 0 0-131.276 30.652l47.38 84.908a198.384 198.384 0 0 1 83.9-18.528z m0 398.36a198.404 198.404 0 0 1-83.896-18.528l-47.38 84.9a294.848 294.848 0 0 0 131.28 30.684 296.16 296.16 0 0 0 79.136-10.788c4.444-27.4 20.708-52.62 46.632-67.608 25.904-14.948 55.836-16.42 81.76-6.624 50.42-49.584 83.232-117.016 88.016-192.128l-97.188-1.432c-8.94 101.772-94.304 181.52-198.36 181.52z m139.552-440.924c32.616 18.832 74.3 7.68 93.116-24.936 18.84-32.616 7.68-74.3-24.936-93.14-32.616-18.816-74.296-7.64-93.14 24.976-18.812 32.6-7.632 74.28 24.96 93.1z" fill="#FFF"/></svg> if (id.startsWith('ubuntu')) return <svg className={size} viewBox="0 0 1024 1024"><circle cx="512" cy="512" r="511" fill="#DD4814"/><path d="M164.532 442.532c-37.676 0-68.2 30.524-68.2 68.2 0 37.656 30.524 68.184 68.2 68.184 37.66 0 68.184-30.528 68.184-68.184 0-37.676-30.524-68.2-68.184-68.2z m486.86 309.912c-32.612 18.84-43.8 60.52-24.96 93.116 18.82 32.616 60.5 43.796 93.116 24.96 32.612-18.82 43.796-60.5 24.96-93.12-18.82-32.592-60.524-43.772-93.116-24.956z m-338.744-241.712c0-67.384 33.472-126.92 84.684-162.968L347.48 264.268c-59.656 39.88-104.048 100.816-122.496 172.188 21.528 17.56 35.304 44.3 35.304 74.272 0 29.956-13.776 56.696-35.304 74.26C243.408 656.376 287.8 717.32 347.48 757.2l49.852-83.52c-51.212-36.028-84.684-95.56-84.684-162.948z m199.168-199.188c104.052 0 189.42 79.776 198.38 181.52l97.16-1.432c-4.776-75.112-37.592-142.544-88.008-192.128-25.928 9.796-55.88 8.296-81.76-6.624-25.932-14.964-42.192-40.208-46.636-67.608a297.04 297.04 0 0 0-79.14-10.76 295.148 295.148 0 0 0-131.276 30.652l47.38 84.908a198.384 198.384 0 0 1 83.9-18.528z m0 398.36a198.404 198.404 0 0 1-83.896-18.528l-47.38 84.9a294.848 294.848 0 0 0 131.28 30.684 296.16 296.16 0 0 0 79.136-10.788c4.444-27.4 20.708-52.62 46.632-67.608 25.904-14.948 55.836-16.42 81.76-6.624 50.42-49.584 83.232-117.016 88.016-192.128l-97.188-1.432c-8.94 101.772-94.304 181.52-198.36 181.52z m139.552-440.924c32.616 18.832 74.3 7.68 93.116-24.936 18.84-32.616 7.68-74.3-24.936-93.14-32.616-18.816-74.296-7.64-93.14 24.976-18.812 32.6-7.632 74.28 24.96 93.1z" fill="#FFF"/></svg>
if (id.startsWith('alpine')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M255.914667 68.565333L0 512l255.914667 443.434667h512.170666L1024 512 768.085333 68.565333H255.914667zM425.173333 303.786667L540.16 422.4l68.181333 68.053333 0.085334-0.085333 102.826666 100.821333c-8.533333 5.973333-16.469333 10.752-24.021333 14.677334a160.256 160.256 0 0 1-21.162667 9.258666 115.285333 115.285333 0 0 1-18.133333 4.736c-5.589333 0.981333-10.666667 1.450667-15.274667 1.450667-5.546667 0-10.325333-0.597333-14.421333-1.450667a56.192 56.192 0 0 1-10.24-3.072 40.533333 40.533333 0 0 1-8.533333-4.821333l-45.312-46.592-129.664-129.749333-46.933334 44.928-130.986666 131.072a41.557333 41.557333 0 0 1-8.533334 4.736 54.357333 54.357333 0 0 1-10.112 3.114666 70.826667 70.826667 0 0 1-14.421333 1.408c-4.608 0-9.685333-0.384-15.274667-1.322666a115.2 115.2 0 0 1-18.133333-4.864 159.914667 159.914667 0 0 1-21.162667-9.258667 223.061333 223.061333 0 0 1-24.021333-14.634667L425.173333 303.786667z m201.386667 33.493333l195.370667 196.181333 58.965333 57.728a223.573333 223.573333 0 0 1-24.064 14.677334 159.146667 159.146667 0 0 1-21.077333 9.258666 115.072 115.072 0 0 1-18.176 4.736c-5.546667 0.981333-10.709333 1.450667-15.36 1.450667-5.504 0-10.282667-0.597333-14.378667-1.450667a54.826667 54.826667 0 0 1-16.426667-6.229333 10.197333 10.197333 0 0 1-2.261333-1.706667l-52.565333-51.968-90.069334-90.069333-14.250666 14.250667L545.706667 418.133333l80.896-80.938666z m-254.549333 175.786667v107.904a90.794667 90.794667 0 0 1-15.189334-1.493334 117.973333 117.973333 0 0 1-18.005333-4.949333 158.208 158.208 0 0 1-20.821333-9.130667 222.592 222.592 0 0 1-23.68-14.506666l77.653333-77.866667z" fill="#0D597F"/></svg> if (id.startsWith('alpine')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M255.914667 68.565333L0 512l255.914667 443.434667h512.170666L1024 512 768.085333 68.565333H255.914667zM425.173333 303.786667L540.16 422.4l68.181333 68.053333 0.085334-0.085333 102.826666 100.821333c-8.533333 5.973333-16.469333 10.752-24.021333 14.677334a160.256 160.256 0 0 1-21.162667 9.258666 115.285333 115.285333 0 0 1-18.133333 4.736c-5.589333 0.981333-10.666667 1.450667-15.274667 1.450667-5.546667 0-10.325333-0.597333-14.421333-1.450667a56.192 56.192 0 0 1-10.24-3.072 40.533333 40.533333 0 0 1-8.533333-4.821333l-45.312-46.592-129.664-129.749333-46.933334 44.928-130.986666 131.072a41.557333 41.557333 0 0 1-8.533334 4.736 54.357333 54.357333 0 0 1-10.112 3.114666 70.826667 70.826667 0 0 1-14.421333 1.408c-4.608 0-9.685333-0.384-15.274667-1.322666a115.2 115.2 0 0 1-18.133333-4.864 159.914667 159.914667 0 0 1-21.162667-9.258667 223.061333 223.061333 0 0 1-24.021333-14.634667L425.173333 303.786667z m201.386667 33.493333l195.370667 196.181333 58.965333 57.728a223.573333 223.573333 0 0 1-24.064 14.677334 159.146667 159.146667 0 0 1-21.077333 9.258666 115.072 115.072 0 0 1-18.176 4.736c-5.546667 0.981333-10.709333 1.450667-15.36 1.450667-5.504 0-10.282667-0.597333-14.378667-1.450667a54.826667 54.826667 0 0 1-16.426667-6.229333 10.197333 10.197333 0 0 1-2.261333-1.706667l-52.565333-51.968-90.069334-90.069333-14.250666 14.250667L545.706667 418.133333l80.896-80.938666z m-254.549333 175.786667v107.904a90.794667 90.794667 0 0 1-15.189334-1.493334 117.973333 117.973333 0 0 1-18.005333-4.949333 158.208 158.208 0 0 1-20.821333-9.130667 222.592 222.592 0 0 1-23.68-14.506666l77.653333-77.866667z" fill="#0D597F"/></svg>
@@ -1813,5 +2066,6 @@ function getTemplateIcon(id: string): ReactNode {
if (id.startsWith('nixos')) return <svg className={size} viewBox="0 0 60 60"><g fillRule="evenodd"><path d="M23.58 20.214L8.964 45.528 5.55 39.743l3.94-6.78-7.823-.02L0 30.052l1.703-2.956 11.135.035 4.002-6.9zM24.7 40.45h29.23l-3.302 5.85-7.84-.022 3.894 6.785-1.67 2.9-3.412.004-5.537-9.66-7.976-.016zm17.014-11.092L27.1 4.043l6.716-.063 3.902 6.8 3.93-6.765 3.337.002 1.7 2.953-5.598 9.626 3.974 6.916z" fill="#7ebae4"/><path d="M35.28 19.486l-29.23-.002 3.303-5.848 7.84.022L13.3 6.873l1.67-2.9 3.412-.004 5.537 9.66 7.976.016zm1.14 20.294l14.616-25.313 3.413 5.785-3.94 6.78 7.823.02 1.668 2.9-1.703 2.956-11.135-.035-4.002 6.9z" fill="#5277c3"/></g><defs><path id="B" d="M18.305 30.642L32.92 55.956l-6.716.063-3.902-6.8-3.93 6.765-3.337-.002-1.71-2.953 5.598-9.626-3.974-6.916z"/></defs></svg> if (id.startsWith('nixos')) return <svg className={size} viewBox="0 0 60 60"><g fillRule="evenodd"><path d="M23.58 20.214L8.964 45.528 5.55 39.743l3.94-6.78-7.823-.02L0 30.052l1.703-2.956 11.135.035 4.002-6.9zM24.7 40.45h29.23l-3.302 5.85-7.84-.022 3.894 6.785-1.67 2.9-3.412.004-5.537-9.66-7.976-.016zm17.014-11.092L27.1 4.043l6.716-.063 3.902 6.8 3.93-6.765 3.337.002 1.7 2.953-5.598 9.626 3.974 6.916z" fill="#7ebae4"/><path d="M35.28 19.486l-29.23-.002 3.303-5.848 7.84.022L13.3 6.873l1.67-2.9 3.412-.004 5.537 9.66 7.976.016zm1.14 20.294l14.616-25.313 3.413 5.785-3.94 6.78 7.823.02 1.668 2.9-1.703 2.956-11.135-.035-4.002 6.9z" fill="#5277c3"/></g><defs><path id="B" d="M18.305 30.642L32.92 55.956l-6.716.063-3.902-6.8-3.93 6.765-3.337-.002-1.71-2.953 5.598-9.626-3.974-6.916z"/></defs></svg>
if (id.startsWith('kali')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M545.194667 253.568s-84.053333-5.546667-227.285334 39.253333c-145.92 45.653333-228.693333 110.378667-228.693333 110.378667s217.514667-121.472 463.018667-128.341333z m313.642666 132.053333l10.965334-0.725333s-62.634667-75.946667-182.528-112.981333c67.413333 27.392 126.037333 63.701333 171.562666 113.706666z m17.92 31.573334c1.664-2.901333 7.082667 9.258667 11.221334 14.378666 0.170667 1.024 0.426667 1.664-1.92 1.152-0.213333-1.066667-0.554667-1.365333-0.554667-1.365333s-5.76-3.413333-7.552-5.845333c-1.749333-2.432-2.090667-6.698667-1.194667-8.32z m147.114667 361.770666s13.312-152.661333-226.56-187.861333a779.818667 779.818667 0 0 0-107.690667-7.978667c-192.256 2.56-199.253333-221.738667-54.4-233.045333 60.032-4.949333 131.712 27.434667 201.813334 60.074667-0.298667 8.704 0.085333 16.426667 5.802666 23.552 5.717333 7.168 27.648 14.933333 34.688 18.986666 6.997333 4.010667 29.482667 18.346667 43.264 36.266667 2.986667-5.589333 27.904-21.845333 27.904-21.845333s-5.973333 0.128-19.84-5.077334c-13.909333-5.205333-30.421333-20.906667-30.805333-21.802666-0.426667-0.938667-0.64-2.346667 2.56-2.986667 2.517333-2.090667-3.072-8.832-5.546667-11.306667-2.474667-2.474667-18.986667-30.549333-19.370666-31.146666-0.384-0.682667-0.512-1.322667-1.706667-2.133334-3.626667-1.152-19.626667 1.706667-19.626667 1.706667s-24.533333-12.074667-33.024-38.101333c0.128 4.565333-4.224 9.557333 0 20.010666-12.8-5.418667-23.808-14.677333-32.512-37.546666-5.12 13.013333 0 21.290667 0 21.290666s-30.165333-8.448-34.986666-36.266666c-5.290667 12.501333 0 20.010667 0 20.010666s-49.194667-25.685333-130.944-26.026666c-54.741333-5.034667-66.133333-101.290667-61.013334-117.504 0 0-78.933333-41.6-234.368-59.989334-155.392-18.346667-282.794667-2.773333-282.794666-2.773333s275.2-13.226667 495.658666 76.074667c7.509333 33.493333 30.037333 89.344 42.197334 116.181333-34.773333 24.021333-73.941333 46.592-80.042667 126.72-6.101333 80.128 62.805333 150.613333 148.224 152.746667 81.066667 4.352 137.130667 4.949333 205.056 40.192 64.853333 35.84 118.016 145.066667 123.306667 243.328 5.632-72.917333-21.717333-229.674667-149.333334-277.248 178.389333 31.232 194.090667 163.498667 194.090667 163.498666zM541.013333 241.621333l-6.4-20.693333s-105.984-18.816-248.405333-8.704C143.786667 222.336 0 272.213333 0 272.213333s294.229333-74.026667 541.013333-30.592z" fill="#557C94"/></svg> if (id.startsWith('kali')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M545.194667 253.568s-84.053333-5.546667-227.285334 39.253333c-145.92 45.653333-228.693333 110.378667-228.693333 110.378667s217.514667-121.472 463.018667-128.341333z m313.642666 132.053333l10.965334-0.725333s-62.634667-75.946667-182.528-112.981333c67.413333 27.392 126.037333 63.701333 171.562666 113.706666z m17.92 31.573334c1.664-2.901333 7.082667 9.258667 11.221334 14.378666 0.170667 1.024 0.426667 1.664-1.92 1.152-0.213333-1.066667-0.554667-1.365333-0.554667-1.365333s-5.76-3.413333-7.552-5.845333c-1.749333-2.432-2.090667-6.698667-1.194667-8.32z m147.114667 361.770666s13.312-152.661333-226.56-187.861333a779.818667 779.818667 0 0 0-107.690667-7.978667c-192.256 2.56-199.253333-221.738667-54.4-233.045333 60.032-4.949333 131.712 27.434667 201.813334 60.074667-0.298667 8.704 0.085333 16.426667 5.802666 23.552 5.717333 7.168 27.648 14.933333 34.688 18.986666 6.997333 4.010667 29.482667 18.346667 43.264 36.266667 2.986667-5.589333 27.904-21.845333 27.904-21.845333s-5.973333 0.128-19.84-5.077334c-13.909333-5.205333-30.421333-20.906667-30.805333-21.802666-0.426667-0.938667-0.64-2.346667 2.56-2.986667 2.517333-2.090667-3.072-8.832-5.546667-11.306667-2.474667-2.474667-18.986667-30.549333-19.370666-31.146666-0.384-0.682667-0.512-1.322667-1.706667-2.133334-3.626667-1.152-19.626667 1.706667-19.626667 1.706667s-24.533333-12.074667-33.024-38.101333c0.128 4.565333-4.224 9.557333 0 20.010666-12.8-5.418667-23.808-14.677333-32.512-37.546666-5.12 13.013333 0 21.290667 0 21.290666s-30.165333-8.448-34.986666-36.266666c-5.290667 12.501333 0 20.010667 0 20.010666s-49.194667-25.685333-130.944-26.026666c-54.741333-5.034667-66.133333-101.290667-61.013334-117.504 0 0-78.933333-41.6-234.368-59.989334-155.392-18.346667-282.794667-2.773333-282.794666-2.773333s275.2-13.226667 495.658666 76.074667c7.509333 33.493333 30.037333 89.344 42.197334 116.181333-34.773333 24.021333-73.941333 46.592-80.042667 126.72-6.101333 80.128 62.805333 150.613333 148.224 152.746667 81.066667 4.352 137.130667 4.949333 205.056 40.192 64.853333 35.84 118.016 145.066667 123.306667 243.328 5.632-72.917333-21.717333-229.674667-149.333334-277.248 178.389333 31.232 194.090667 163.498667 194.090667 163.498666zM541.013333 241.621333l-6.4-20.693333s-105.984-18.816-248.405333-8.704C143.786667 222.336 0 272.213333 0 272.213333s294.229333-74.026667 541.013333-30.592z" fill="#557C94"/></svg>
if (id.startsWith('rockylinux')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M995.498667 680.362667c18.474667-52.778667 28.501333-109.568 28.501333-168.704C1024 229.077333 794.752 0 512 0S0 229.077333 0 511.658667c0 139.818667 56.106667 266.496 147.114667 358.826666L666.453333 351.530667l128.213334 128.170666 200.832 200.704z m-93.525334 162.816l-235.52-235.349334-368.896 368.597334A510.506667 510.506667 0 0 0 512 1023.274667c156.16 0 296.106667-69.888 389.973333-180.053334h0.042667z" fill="#10B981"/></svg> if (id.startsWith('rockylinux')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M995.498667 680.362667c18.474667-52.778667 28.501333-109.568 28.501333-168.704C1024 229.077333 794.752 0 512 0S0 229.077333 0 511.658667c0 139.818667 56.106667 266.496 147.114667 358.826666L666.453333 351.530667l128.213334 128.170666 200.832 200.704z m-93.525334 162.816l-235.52-235.349334-368.896 368.597334A510.506667 510.506667 0 0 0 512 1023.274667c156.16 0 296.106667-69.888 389.973333-180.053334h0.042667z" fill="#10B981"/></svg>
if (id.startsWith('windows')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M56.888889 227.555556l398.222222-70.542223V512H56.888889V227.555556z m0 625.777777l398.222222 70.542223V568.888889H56.888889v284.444444zM512 147.342222L1024 56.888889v455.111111H512V147.342222z m0 786.204445L1024 1024v-455.111111H512v364.657778z" fill="#16C6FE"/></svg>
return null return null
} }
+142 -80
View File
@@ -48,6 +48,7 @@ export default function Containers() {
const [tasks, setTasks] = useState<Task[]>([]) const [tasks, setTasks] = useState<Task[]>([])
const [queuedCreates, setQueuedCreates] = useState<Record<string, CreateContainerRequest>>({}) const [queuedCreates, setQueuedCreates] = useState<Record<string, CreateContainerRequest>>({})
const [searchText, setSearchText] = useState('') const [searchText, setSearchText] = useState('')
const [typeFilter, setTypeFilter] = useState('all')
const [systemFilter, setSystemFilter] = useState('all') const [systemFilter, setSystemFilter] = useState('all')
const [statusFilter, setStatusFilter] = useState('all') const [statusFilter, setStatusFilter] = useState('all')
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
@@ -168,12 +169,13 @@ export default function Containers() {
const filteredContainers = useMemo(() => { const filteredContainers = useMemo(() => {
return filterContainers(displayContainers, { return filterContainers(displayContainers, {
search: searchText, search: searchText,
type: typeFilter,
system: systemFilter, system: systemFilter,
status: statusFilter, status: statusFilter,
taskStatusMap, taskStatusMap,
taskNameMap, taskNameMap,
}) })
}, [displayContainers, searchText, systemFilter, statusFilter, tasks]) }, [displayContainers, searchText, typeFilter, systemFilter, statusFilter, tasks])
const totalPages = Math.max(1, Math.ceil(filteredContainers.length / pageSize)) const totalPages = Math.max(1, Math.ceil(filteredContainers.length / pageSize))
const currentPage = Math.min(page, totalPages) const currentPage = Math.min(page, totalPages)
const pageStart = (currentPage - 1) * pageSize const pageStart = (currentPage - 1) * pageSize
@@ -185,7 +187,7 @@ export default function Containers() {
useEffect(() => { useEffect(() => {
setPage(1) setPage(1)
}, [searchText, systemFilter, statusFilter, pageSize]) }, [searchText, typeFilter, systemFilter, statusFilter, pageSize])
const toggleAll = () => { const toggleAll = () => {
if (allFilteredSelected) { if (allFilteredSelected) {
@@ -235,70 +237,6 @@ export default function Containers() {
</p> </p>
</div> </div>
<div className="flex flex-wrap items-center justify-end gap-2"> <div className="flex flex-wrap items-center justify-end gap-2">
{selected.size > 0 && (
<div className="flex items-center gap-1.5 bg-gray-50 border border-gray-200 rounded-md px-3 py-1.5">
<span className="text-xs text-gray-500 mr-1">{selected.size} </span>
<button onClick={() => handleBatchAction('start')} disabled={batchLoading || hasActiveTasks(tasks)} className="inline-flex items-center gap-1 px-2.5 py-1 text-xs text-gray-700 hover:bg-gray-200 rounded border border-gray-300 disabled:opacity-50 disabled:cursor-not-allowed">
<Play className="w-3 h-3" />{batchLoading ? '执行中...' : '开机'}
</button>
<button onClick={() => handleBatchAction('stop')} disabled={batchLoading || hasActiveTasks(tasks)} className="inline-flex items-center gap-1 px-2.5 py-1 text-xs text-gray-700 hover:bg-gray-200 rounded border border-gray-300 disabled:opacity-50 disabled:cursor-not-allowed">
<Square className="w-3 h-3" />{batchLoading ? '执行中...' : '关机'}
</button>
<button onClick={() => handleBatchAction('restart')} disabled={batchLoading || hasActiveTasks(tasks)} className="inline-flex items-center gap-1 px-2.5 py-1 text-xs text-gray-700 hover:bg-gray-200 rounded border border-gray-300 disabled:opacity-50 disabled:cursor-not-allowed">
<RotateCcw className="w-3 h-3" />{batchLoading ? '执行中...' : '重启'}
</button>
<button onClick={() => handleBatchAction('delete')} disabled={batchLoading || hasActiveTasks(tasks)} className="inline-flex items-center gap-1 px-2.5 py-1 text-xs text-red-600 hover:bg-red-50 rounded border border-red-200 disabled:opacity-50 disabled:cursor-not-allowed">
<Trash2 className="w-3 h-3" />{batchLoading ? '执行中...' : '删除'}
</button>
</div>
)}
{displayContainers.length > 0 && (
<>
<div className="relative w-[260px]">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-gray-400" />
<input
value={searchText}
onChange={(event) => setSearchText(event.target.value)}
className="h-8 w-full rounded-md border border-gray-300 bg-white pl-8 pr-2 text-xs text-black outline-none focus:border-black focus:ring-2 focus:ring-black"
placeholder="搜索名称、ID、UUID、IP"
/>
</div>
<select
value={systemFilter}
onChange={(event) => setSystemFilter(event.target.value)}
className="h-8 rounded-md border border-gray-300 bg-white px-2 text-xs text-gray-700 outline-none focus:border-black focus:ring-2 focus:ring-black"
title="系统筛选"
>
<option value="all"></option>
{systemOptions.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
<select
value={statusFilter}
onChange={(event) => setStatusFilter(event.target.value)}
className="h-8 rounded-md border border-gray-300 bg-white px-2 text-xs text-gray-700 outline-none focus:border-black focus:ring-2 focus:ring-black"
title="状态筛选"
>
<option value="all"></option>
<option value="running">线</option>
<option value="stopped">线</option>
<option value="task"></option>
<option value="creating"></option>
<option value="failed"></option>
</select>
<select
value={pageSize}
onChange={(event) => setPageSize(Number(event.target.value))}
className="h-8 rounded-md border border-gray-300 bg-white px-2 text-xs text-gray-700 outline-none focus:border-black focus:ring-2 focus:ring-black"
title="每页数量"
>
<option value={10}>10 / </option>
<option value={20}>20 / </option>
<option value={50}>50 / </option>
</select>
</>
)}
<button <button
onClick={handleRefreshList} onClick={handleRefreshList}
disabled={refreshing} disabled={refreshing}
@@ -332,6 +270,84 @@ export default function Containers() {
</div> </div>
</div> </div>
{displayContainers.length > 0 && (
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex flex-wrap items-center gap-2">
<div className="relative w-[260px]">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-gray-400" />
<input
value={searchText}
onChange={(event) => setSearchText(event.target.value)}
className="h-8 w-full rounded-md border border-gray-300 bg-white pl-8 pr-2 text-xs text-black outline-none focus:border-black focus:ring-2 focus:ring-black"
placeholder="搜索名称、ID、UUID、IP"
/>
</div>
<select
value={typeFilter}
onChange={(event) => setTypeFilter(event.target.value)}
className="h-8 rounded-md border border-gray-300 bg-white px-2 text-xs text-gray-700 outline-none focus:border-black focus:ring-2 focus:ring-black"
title="类型筛选"
>
<option value="all"></option>
<option value="lxc">LXC</option>
<option value="kvm">KVM</option>
</select>
<select
value={systemFilter}
onChange={(event) => setSystemFilter(event.target.value)}
className="h-8 rounded-md border border-gray-300 bg-white px-2 text-xs text-gray-700 outline-none focus:border-black focus:ring-2 focus:ring-black"
title="系统筛选"
>
<option value="all"></option>
{systemOptions.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
<select
value={statusFilter}
onChange={(event) => setStatusFilter(event.target.value)}
className="h-8 rounded-md border border-gray-300 bg-white px-2 text-xs text-gray-700 outline-none focus:border-black focus:ring-2 focus:ring-black"
title="状态筛选"
>
<option value="all"></option>
<option value="running">线</option>
<option value="stopped">线</option>
<option value="task"></option>
<option value="creating"></option>
<option value="failed"></option>
</select>
<select
value={pageSize}
onChange={(event) => setPageSize(Number(event.target.value))}
className="h-8 rounded-md border border-gray-300 bg-white px-2 text-xs text-gray-700 outline-none focus:border-black focus:ring-2 focus:ring-black"
title="每页数量"
>
<option value={10}>10 / </option>
<option value={20}>20 / </option>
<option value={50}>50 / </option>
</select>
</div>
{selected.size > 0 && (
<div className="flex flex-wrap items-center justify-end gap-1.5">
<span className="text-xs text-gray-500">{selected.size} </span>
<button onClick={() => handleBatchAction('start')} disabled={batchLoading || hasActiveTasks(tasks)} className="inline-flex h-8 items-center gap-1 px-2.5 text-xs text-gray-700 hover:bg-gray-100 rounded border border-gray-300 disabled:opacity-50 disabled:cursor-not-allowed">
<Play className="w-3 h-3" />{batchLoading ? '执行中...' : '开机'}
</button>
<button onClick={() => handleBatchAction('stop')} disabled={batchLoading || hasActiveTasks(tasks)} className="inline-flex h-8 items-center gap-1 px-2.5 text-xs text-gray-700 hover:bg-gray-100 rounded border border-gray-300 disabled:opacity-50 disabled:cursor-not-allowed">
<Square className="w-3 h-3" />{batchLoading ? '执行中...' : '关机'}
</button>
<button onClick={() => handleBatchAction('restart')} disabled={batchLoading || hasActiveTasks(tasks)} className="inline-flex h-8 items-center gap-1 px-2.5 text-xs text-gray-700 hover:bg-gray-100 rounded border border-gray-300 disabled:opacity-50 disabled:cursor-not-allowed">
<RotateCcw className="w-3 h-3" />{batchLoading ? '执行中...' : '重启'}
</button>
<button onClick={() => handleBatchAction('delete')} disabled={batchLoading || hasActiveTasks(tasks)} className="inline-flex h-8 items-center gap-1 px-2.5 text-xs text-red-600 hover:bg-red-50 rounded border border-red-200 disabled:opacity-50 disabled:cursor-not-allowed">
<Trash2 className="w-3 h-3" />{batchLoading ? '执行中...' : '删除'}
</button>
</div>
)}
</div>
)}
{displayContainers.length === 0 ? ( {displayContainers.length === 0 ? (
<div className="bg-white border border-gray-200 rounded-lg p-12 text-center"> <div className="bg-white border border-gray-200 rounded-lg p-12 text-center">
<div className="w-16 h-16 bg-gray-100 rounded-lg flex items-center justify-center mx-auto mb-4"> <div className="w-16 h-16 bg-gray-100 rounded-lg flex items-center justify-center mx-auto mb-4">
@@ -343,7 +359,7 @@ export default function Containers() {
) : ( ) : (
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden"> <div className="bg-white border border-gray-200 rounded-lg overflow-hidden">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full min-w-[1200px]"> <table className="w-full min-w-[1260px]">
<thead> <thead>
<tr className="border-b border-gray-200 bg-gray-50"> <tr className="border-b border-gray-200 bg-gray-50">
<th className="w-10 px-3 py-3"> <th className="w-10 px-3 py-3">
@@ -361,6 +377,7 @@ export default function Containers() {
<TableHead></TableHead> <TableHead></TableHead>
<TableHead></TableHead> <TableHead></TableHead>
<TableHead></TableHead> <TableHead></TableHead>
<TableHead></TableHead>
<TableHead icon><Cpu className="w-3.5 h-3.5" />CPU</TableHead> <TableHead icon><Cpu className="w-3.5 h-3.5" />CPU</TableHead>
<TableHead icon><MemoryStick className="w-3.5 h-3.5" />MEMORY</TableHead> <TableHead icon><MemoryStick className="w-3.5 h-3.5" />MEMORY</TableHead>
<TableHead icon><HardDrive className="w-3.5 h-3.5" />DISK</TableHead> <TableHead icon><HardDrive className="w-3.5 h-3.5" />DISK</TableHead>
@@ -375,11 +392,18 @@ export default function Containers() {
const isRunning = container.status === 'running' const isRunning = container.status === 'running'
const task = (container.id > 0 ? taskStatusMap[container.id] : taskNameMap[container.name]) || container.createTask const task = (container.id > 0 ? taskStatusMap[container.id] : taskNameMap[container.name]) || container.createTask
const isPlaceholder = !!container.isPlaceholder const isPlaceholder = !!container.isPlaceholder
const isPolicyBlocked = !!container.policy_blocked
const usage = usageByName[container.name] const usage = usageByName[container.name]
const isKVM = (container.virtualization || 'lxc') === 'kvm'
const cpuPct = isRunning ? clamp(usage?.cpu_usage_pct || 0) : 0 const cpuPct = isRunning
const ramPct = isRunning && container.ram_mb > 0 ? clamp((usage?.cpu_usage_pct || 0) / (isKVM ? (container.vcpu || 1) : 1))
? clamp(((usage?.memory_usage_bytes || 0) / (container.ram_mb * 1024 * 1024)) * 100) : 0
const ramTotalBytes = usage?.memory_total_bytes && usage.memory_total_bytes > 0
? usage.memory_total_bytes
: container.ram_mb * 1024 * 1024
const ramPct = isRunning && ramTotalBytes > 0
? clamp(((usage?.memory_usage_bytes || 0) / ramTotalBytes) * 100)
: 0 : 0
const diskPct = container.disk_gb > 0 const diskPct = container.disk_gb > 0
? clamp(((usage?.disk_usage_bytes || 0) / (container.disk_gb * 1024 * 1024 * 1024)) * 100) ? clamp(((usage?.disk_usage_bytes || 0) / (container.disk_gb * 1024 * 1024 * 1024)) * 100)
@@ -413,7 +437,7 @@ export default function Containers() {
</button> </button>
</td> </td>
<td className="px-2.5 py-2 align-top"> <td className="px-2.5 py-2 align-top">
<StatusBadge running={isRunning} task={task} placeholder={isPlaceholder} /> <StatusBadge running={isRunning} task={task} placeholder={isPlaceholder} policyBlocked={isPolicyBlocked} />
</td> </td>
<td className="px-2.5 py-2 align-top text-xs text-gray-600 whitespace-nowrap"> <td className="px-2.5 py-2 align-top text-xs text-gray-600 whitespace-nowrap">
<span className="inline-flex items-center gap-1"> <span className="inline-flex items-center gap-1">
@@ -421,6 +445,9 @@ export default function Containers() {
{getTemplateName(container.template)} {getTemplateName(container.template)}
</span> </span>
</td> </td>
<td className="px-2.5 py-2 align-top">
<RuntimeBadge runtime={container.virtualization || 'lxc'} />
</td>
<td className="px-2.5 py-2 align-top"> <td className="px-2.5 py-2 align-top">
<ProgressCell pct={cpuPct} /> <ProgressCell pct={cpuPct} />
</td> </td>
@@ -554,8 +581,17 @@ type DisplayContainer = Container & {
createTask?: Task createTask?: Task
} }
function StatusBadge({ running, task, placeholder }: { running: boolean; task?: Task; placeholder?: boolean }) { function StatusBadge({ running, task, placeholder, policyBlocked }: { running: 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" const baseClass = "inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium whitespace-nowrap"
if (policyBlocked) {
return (
<span className={`${baseClass} bg-red-50 text-red-700`}>
<span className="w-1.5 h-1.5 rounded-full bg-red-500"></span>
</span>
)
}
if (task?.status === 'failed') { if (task?.status === 'failed') {
return ( return (
<span className={`${baseClass} bg-red-50 text-red-700`}> <span className={`${baseClass} bg-red-50 text-red-700`}>
@@ -612,6 +648,15 @@ function StatusBadge({ running, task, placeholder }: { running: boolean; task?:
) )
} }
function RuntimeBadge({ runtime }: { runtime: string }) {
const normalized = runtime === 'kvm' ? 'kvm' : 'lxc'
return (
<span className={`inline-flex rounded px-2 py-0.5 text-[11px] font-medium ${normalized === 'kvm' ? 'bg-indigo-50 text-indigo-700' : 'bg-gray-100 text-gray-700'}`}>
{normalized.toUpperCase()}
</span>
)
}
function buildDisplayContainers( function buildDisplayContainers(
containers: Container[], containers: Container[],
queuedCreates: Record<string, CreateContainerRequest>, queuedCreates: Record<string, CreateContainerRequest>,
@@ -643,6 +688,7 @@ function toPlaceholder(cfg: CreateContainerRequest): DisplayContainer {
id: 0, id: 0,
uuid: '', uuid: '',
name: cfg.name, name: cfg.name,
virtualization: cfg.virtualization || 'lxc',
template: cfg.template_id, template: cfg.template_id,
vcpu: cfg.vcpu, vcpu: cfg.vcpu,
ram_mb: cfg.ram_mb, ram_mb: cfg.ram_mb,
@@ -715,6 +761,7 @@ function hasActiveTasks(tasks: Task[]) {
type ContainerFilters = { type ContainerFilters = {
search: string search: string
type: string
system: string system: string
status: string status: string
taskStatusMap: Record<number, Task> taskStatusMap: Record<number, Task>
@@ -728,6 +775,9 @@ function filterContainers(containers: DisplayContainer[], filters: ContainerFilt
if (filters.system !== 'all' && getSystemFilterValue(container.template) !== filters.system) { if (filters.system !== 'all' && getSystemFilterValue(container.template) !== filters.system) {
return false return false
} }
if (filters.type !== 'all' && (container.virtualization || 'lxc') !== filters.type) {
return false
}
if (filters.status !== 'all' && getContainerStatusFilterValue(container, task) !== filters.status) { if (filters.status !== 'all' && getContainerStatusFilterValue(container, task) !== filters.status) {
return false return false
} }
@@ -740,6 +790,7 @@ function filterContainers(containers: DisplayContainer[], filters: ContainerFilt
container.ip, container.ip,
container.ipv6, container.ipv6,
container.template, container.template,
container.virtualization || 'lxc',
getTemplateName(container.template), getTemplateName(container.template),
getSystemFilterLabel(getSystemFilterValue(container.template)), getSystemFilterLabel(getSystemFilterValue(container.template)),
String(container.ssh_port || ''), String(container.ssh_port || ''),
@@ -760,14 +811,16 @@ function buildSystemOptions(containers: DisplayContainer[]) {
} }
function getSystemFilterValue(template: string) { function getSystemFilterValue(template: string) {
if (template.startsWith('ubuntu')) return 'ubuntu' const normalized = template.startsWith('kvm-') ? template.slice(4) : template
if (template.startsWith('debian')) return 'debian' if (normalized.startsWith('ubuntu')) return 'ubuntu'
if (template.startsWith('alpine')) return 'alpine' if (normalized.startsWith('debian')) return 'debian'
if (template.startsWith('centos')) return 'centos' if (normalized.startsWith('alpine')) return 'alpine'
if (template.startsWith('archlinux')) return 'archlinux' if (normalized.startsWith('centos')) return 'centos'
if (template.startsWith('fedora')) return 'fedora' if (normalized.startsWith('archlinux')) return 'archlinux'
if (template.startsWith('rockylinux')) return 'rockylinux' if (normalized.startsWith('fedora')) return 'fedora'
return template || 'unknown' if (normalized.startsWith('rockylinux')) return 'rockylinux'
if (normalized.startsWith('windows')) return 'windows'
return normalized || 'unknown'
} }
function getSystemFilterLabel(system: string) { function getSystemFilterLabel(system: string) {
@@ -779,6 +832,7 @@ function getSystemFilterLabel(system: string) {
archlinux: 'Arch Linux', archlinux: 'Arch Linux',
fedora: 'Fedora', fedora: 'Fedora',
rockylinux: 'Rocky Linux', rockylinux: 'Rocky Linux',
windows: 'Windows',
unknown: '未知系统', unknown: '未知系统',
} }
return labels[system] || system return labels[system] || system
@@ -903,12 +957,19 @@ function getTemplateName(id: string) {
'archlinux-current': 'Arch Linux', 'archlinux-current': 'Arch Linux',
'fedora-44': 'Fedora 44', 'fedora-44': 'Fedora 44',
'rockylinux-10': 'Rocky 10', 'rockylinux-10': 'Rocky 10',
'kvm-ubuntu-noble': 'Ubuntu 24.04',
'kvm-ubuntu-jammy': 'Ubuntu 22.04',
'kvm-debian-bookworm': 'Debian 12',
'kvm-debian-bullseye': 'Debian 11',
'kvm-rockylinux-9': 'Rocky 9',
'kvm-windows-10': 'Windows 10',
} }
return map[id] || id return map[id] || id
} }
function getTemplateIcon(id: string): ReactNode { function getTemplateIcon(id: string): ReactNode {
const size = 'w-4 h-4' const size = 'w-4 h-4'
id = id.startsWith('kvm-') ? id.slice(4) : id
if (id.startsWith('debian')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M935.473 375.359a558.602 558.602 0 0 0-22.351-114.655l13.308 4.436c-35.66-81.385-90.086-163.623-153.556-199.282-8.701-5.118-35.147 4.948-26.616-12.113s-37.536-8.19-56.816-4.778c-26.275 4.266-30.028-29.175-75.071-35.83-25.593-3.582-32.247 18.427-44.702 13.309-23.545-9.384-20.816-27.64-57.669-9.384-18.427 9.042 11.602-26.105-49.138-4.607L457.744 0C349.23 41.63 318.69 76.266 288.15 79.337c-6.996 0-34.124 32.759-53.574 53.062-17.062 17.062-26.275 36.512-49.138 39.583l-17.062 70.636A136.494 136.494 0 0 0 119.41 339.7a66.711 66.711 0 0 1 4.436-52.892c-17.062 6.825-45.896 17.062-29.687 96.91 12.796 63.13-5.29 135.13 10.066 204.742 4.777 20.986 0 40.095 6.142 51.185 107.66 235.794 208.836 392.08 472.44 384.06l4.436-8.872c-28.152-6.825-55.11-17.062-111.584-30.711-18.597-4.436-23.033-34.124-40.265-44.19-9.384-5.46-28.323-4.095-37.195-9.896s4.266-21.668-19.962-14.332c-8.531 2.56-13.82-10.92-20.133-17.061s0-23.716-23.375-24.74-18.426-29.687-19.791-44.702c-12.114 1.536-1.195-1.535-13.308 4.436a63.64 63.64 0 0 1-23.887-31.735c-10.237-48.967-10.578-21.497-15.014-32.417a322.297 322.297 0 0 0-19.28-42.142l26.787 8.872h4.436l4.436-13.309-26.616-8.701h31.223c-7.678 13.99 2.047 5.29-13.479 8.872v13.308l22.35-8.872v-13.308c-20.644-10.237-28.663-13.308-49.137-22.01l9.043 8.872v4.436h-49.138c-22.01-14.843-13.99-31.734-17.915-53.062 17.062 0 9.213 6.655 17.062-13.137l-17.062 8.872 13.308-33.953-13.308 13.138c-29.176-38.73-16.209-97.764-11.943-152.02A180.684 180.684 0 0 1 211.2 372.97c8.872-10.067 5.119-25.251 5.46-37.195l31.223-26.445H265.8c7.678 17.061 4.777 5.46 0 22.01l8.872 4.435c7.166-8.701 6.142-5.971 8.872-22.01-10.066-10.578-6.995-9.895-26.616-13.308A119.432 119.432 0 0 1 368.51 243.13l4.436-13.308-17.915 9.043-4.436-13.138a109.536 109.536 0 0 1 76.095-27.128c6.313 0 6.996-17.062 12.797-19.45 161.574-60.57 309.33 9.383 371.093 147.413a324.173 324.173 0 0 1 8.19 34.123c17.061 56.987-7.167 121.48 9.725 155.604-7.849 36-36.683 13.82-40.266 30.881-8.531 41.29-14.844 59.717-40.778 78.826a196.38 196.38 0 0 1-30.711 22.35 84.285 84.285 0 0 0 22.35-39.753c-106.294 111.584-262.58 63.981-290.049-105.954a101.176 101.176 0 0 1 35.147-93.157c92.987-87.527 150.144-52.38 205.765-20.474l-8.872-30.711c-32.93-24.398-17.062-19.792-9.043-57.328v-4.436l-17.915-13.137c2.56 10.066 1.024 5.289 9.043 17.061-4.436 16.039 0 9.043-8.872 17.062-15.014 9.725-23.716 7.337-44.702 4.436l4.436-13.308-13.308-13.308c0 11.773-4.095 2.73 0 17.062-126.086 9.896-218.05 80.02-178.636 260.191a220.608 220.608 0 0 0 8.872 44.19l-8.872 8.702-4.436-26.446h-13.48l-4.435 13.308c-12.626-25.763-0.853 10.75 40.265 52.892a149.29 149.29 0 0 0 12.797 12.625c47.773 34.124 113.29 81.385 201.328 49.138h9.043v-4.436l-102.37-13.308-4.436-8.701c106.806 24.74 176.93-8.531 236.646-48.456 13.138-17.062 11.431-24.057 22.18-9.043 19.28-17.061 3.925-26.786 13.48-44.019 6.483-11.772 32.587-17.062 44.7-35.318l40.096-136.494h-17.062c3.071-14.332 22.522-34.123-4.436-48.455-2.559-1.536 9.043-1.365 8.872-4.266a145.537 145.537 0 0 0-22.18-66.37c33.1 21.669 36.342 68.247 53.574 105.783v8.872h4.436V375.36zM453.308 595.455l-9.555-26.446 62.446 57.328zM146.196 211.736l-23.204-4.436v39.754c16.72-10.578 18.939-10.407 22.35-35.318z m574.981 176.419a57.498 57.498 0 0 0-17.062 44.19l13.48 8.701a37.877 37.877 0 0 0 4.435-52.891zM868.42 555.872c26.275-11.602 54.598-58.01 35.83-97.081l-35.83 96.91z m-174.03-79.508c-15.697 11.773-19.791 13.308-22.35 39.754l13.307 8.872 17.915-8.872a60.228 60.228 0 0 0 4.436-48.455c-8.36 13.478-2.559 20.644-13.308 8.701z m-67.053 79.508c15.868-10.92 11.944-14.844 17.915-22.18v-4.778a292.097 292.097 0 0 1-62.446 0c-13.137-13.99-13.308-29.346-31.223-39.583 17.062 35.147 3.242 38.218 31.223 61.764a158.162 158.162 0 0 0 40.095 4.436c1.536 0-6.824-1.024 4.436 0zM207.79 520.554H194.31l-8.872 8.702c9.555 10.237 5.46 7.166 13.308-4.436L212.225 547l4.436-17.062-8.872-8.701z m17.062 57.328l4.436-8.873c-10.067-8.701 0-3.583-13.308 0l-13.309-17.061 4.436 17.061v8.873h17.062z" fill="#CE0C48"/></svg> if (id.startsWith('debian')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M935.473 375.359a558.602 558.602 0 0 0-22.351-114.655l13.308 4.436c-35.66-81.385-90.086-163.623-153.556-199.282-8.701-5.118-35.147 4.948-26.616-12.113s-37.536-8.19-56.816-4.778c-26.275 4.266-30.028-29.175-75.071-35.83-25.593-3.582-32.247 18.427-44.702 13.309-23.545-9.384-20.816-27.64-57.669-9.384-18.427 9.042 11.602-26.105-49.138-4.607L457.744 0C349.23 41.63 318.69 76.266 288.15 79.337c-6.996 0-34.124 32.759-53.574 53.062-17.062 17.062-26.275 36.512-49.138 39.583l-17.062 70.636A136.494 136.494 0 0 0 119.41 339.7a66.711 66.711 0 0 1 4.436-52.892c-17.062 6.825-45.896 17.062-29.687 96.91 12.796 63.13-5.29 135.13 10.066 204.742 4.777 20.986 0 40.095 6.142 51.185 107.66 235.794 208.836 392.08 472.44 384.06l4.436-8.872c-28.152-6.825-55.11-17.062-111.584-30.711-18.597-4.436-23.033-34.124-40.265-44.19-9.384-5.46-28.323-4.095-37.195-9.896s4.266-21.668-19.962-14.332c-8.531 2.56-13.82-10.92-20.133-17.061s0-23.716-23.375-24.74-18.426-29.687-19.791-44.702c-12.114 1.536-1.195-1.535-13.308 4.436a63.64 63.64 0 0 1-23.887-31.735c-10.237-48.967-10.578-21.497-15.014-32.417a322.297 322.297 0 0 0-19.28-42.142l26.787 8.872h4.436l4.436-13.309-26.616-8.701h31.223c-7.678 13.99 2.047 5.29-13.479 8.872v13.308l22.35-8.872v-13.308c-20.644-10.237-28.663-13.308-49.137-22.01l9.043 8.872v4.436h-49.138c-22.01-14.843-13.99-31.734-17.915-53.062 17.062 0 9.213 6.655 17.062-13.137l-17.062 8.872 13.308-33.953-13.308 13.138c-29.176-38.73-16.209-97.764-11.943-152.02A180.684 180.684 0 0 1 211.2 372.97c8.872-10.067 5.119-25.251 5.46-37.195l31.223-26.445H265.8c7.678 17.061 4.777 5.46 0 22.01l8.872 4.435c7.166-8.701 6.142-5.971 8.872-22.01-10.066-10.578-6.995-9.895-26.616-13.308A119.432 119.432 0 0 1 368.51 243.13l4.436-13.308-17.915 9.043-4.436-13.138a109.536 109.536 0 0 1 76.095-27.128c6.313 0 6.996-17.062 12.797-19.45 161.574-60.57 309.33 9.383 371.093 147.413a324.173 324.173 0 0 1 8.19 34.123c17.061 56.987-7.167 121.48 9.725 155.604-7.849 36-36.683 13.82-40.266 30.881-8.531 41.29-14.844 59.717-40.778 78.826a196.38 196.38 0 0 1-30.711 22.35 84.285 84.285 0 0 0 22.35-39.753c-106.294 111.584-262.58 63.981-290.049-105.954a101.176 101.176 0 0 1 35.147-93.157c92.987-87.527 150.144-52.38 205.765-20.474l-8.872-30.711c-32.93-24.398-17.062-19.792-9.043-57.328v-4.436l-17.915-13.137c2.56 10.066 1.024 5.289 9.043 17.061-4.436 16.039 0 9.043-8.872 17.062-15.014 9.725-23.716 7.337-44.702 4.436l4.436-13.308-13.308-13.308c0 11.773-4.095 2.73 0 17.062-126.086 9.896-218.05 80.02-178.636 260.191a220.608 220.608 0 0 0 8.872 44.19l-8.872 8.702-4.436-26.446h-13.48l-4.435 13.308c-12.626-25.763-0.853 10.75 40.265 52.892a149.29 149.29 0 0 0 12.797 12.625c47.773 34.124 113.29 81.385 201.328 49.138h9.043v-4.436l-102.37-13.308-4.436-8.701c106.806 24.74 176.93-8.531 236.646-48.456 13.138-17.062 11.431-24.057 22.18-9.043 19.28-17.061 3.925-26.786 13.48-44.019 6.483-11.772 32.587-17.062 44.7-35.318l40.096-136.494h-17.062c3.071-14.332 22.522-34.123-4.436-48.455-2.559-1.536 9.043-1.365 8.872-4.266a145.537 145.537 0 0 0-22.18-66.37c33.1 21.669 36.342 68.247 53.574 105.783v8.872h4.436V375.36zM453.308 595.455l-9.555-26.446 62.446 57.328zM146.196 211.736l-23.204-4.436v39.754c16.72-10.578 18.939-10.407 22.35-35.318z m574.981 176.419a57.498 57.498 0 0 0-17.062 44.19l13.48 8.701a37.877 37.877 0 0 0 4.435-52.891zM868.42 555.872c26.275-11.602 54.598-58.01 35.83-97.081l-35.83 96.91z m-174.03-79.508c-15.697 11.773-19.791 13.308-22.35 39.754l13.307 8.872 17.915-8.872a60.228 60.228 0 0 0 4.436-48.455c-8.36 13.478-2.559 20.644-13.308 8.701z m-67.053 79.508c15.868-10.92 11.944-14.844 17.915-22.18v-4.778a292.097 292.097 0 0 1-62.446 0c-13.137-13.99-13.308-29.346-31.223-39.583 17.062 35.147 3.242 38.218 31.223 61.764a158.162 158.162 0 0 0 40.095 4.436c1.536 0-6.824-1.024 4.436 0zM207.79 520.554H194.31l-8.872 8.702c9.555 10.237 5.46 7.166 13.308-4.436L212.225 547l4.436-17.062-8.872-8.701z m17.062 57.328l4.436-8.873c-10.067-8.701 0-3.583-13.308 0l-13.309-17.061 4.436 17.061v8.873h17.062z" fill="#CE0C48"/></svg>
if (id.startsWith('ubuntu')) return <svg className={size} viewBox="0 0 1024 1024"><circle cx="512" cy="512" r="511" fill="#DD4814"/><path d="M164.532 442.532c-37.676 0-68.2 30.524-68.2 68.2 0 37.656 30.524 68.184 68.2 68.184 37.66 0 68.184-30.528 68.184-68.184 0-37.676-30.524-68.2-68.184-68.2z m486.86 309.912c-32.612 18.84-43.8 60.52-24.96 93.116 18.82 32.616 60.5 43.796 93.116 24.96 32.612-18.82 43.796-60.5 24.96-93.12-18.82-32.592-60.524-43.772-93.116-24.956z m-338.744-241.712c0-67.384 33.472-126.92 84.684-162.968L347.48 264.268c-59.656 39.88-104.048 100.816-122.496 172.188 21.528 17.56 35.304 44.3 35.304 74.272 0 29.956-13.776 56.696-35.304 74.26C243.408 656.376 287.8 717.32 347.48 757.2l49.852-83.52c-51.212-36.028-84.684-95.56-84.684-162.948z m199.168-199.188c104.052 0 189.42 79.776 198.38 181.52l97.16-1.432c-4.776-75.112-37.592-142.544-88.008-192.128-25.928 9.796-55.88 8.296-81.76-6.624-25.932-14.964-42.192-40.208-46.636-67.608a297.04 297.04 0 0 0-79.14-10.76 295.148 295.148 0 0 0-131.276 30.652l47.38 84.908a198.384 198.384 0 0 1 83.9-18.528z m0 398.36a198.404 198.404 0 0 1-83.896-18.528l-47.38 84.9a294.848 294.848 0 0 0 131.28 30.684 296.16 296.16 0 0 0 79.136-10.788c4.444-27.4 20.708-52.62 46.632-67.608 25.904-14.948 55.836-16.42 81.76-6.624 50.42-49.584 83.232-117.016 88.016-192.128l-97.188-1.432c-8.94 101.772-94.304 181.52-198.36 181.52z m139.552-440.924c32.616 18.832 74.3 7.68 93.116-24.936 18.84-32.616 7.68-74.3-24.936-93.14-32.616-18.816-74.296-7.64-93.14 24.976-18.812 32.6-7.632 74.28 24.96 93.1z" fill="#FFF"/></svg> if (id.startsWith('ubuntu')) return <svg className={size} viewBox="0 0 1024 1024"><circle cx="512" cy="512" r="511" fill="#DD4814"/><path d="M164.532 442.532c-37.676 0-68.2 30.524-68.2 68.2 0 37.656 30.524 68.184 68.2 68.184 37.66 0 68.184-30.528 68.184-68.184 0-37.676-30.524-68.2-68.184-68.2z m486.86 309.912c-32.612 18.84-43.8 60.52-24.96 93.116 18.82 32.616 60.5 43.796 93.116 24.96 32.612-18.82 43.796-60.5 24.96-93.12-18.82-32.592-60.524-43.772-93.116-24.956z m-338.744-241.712c0-67.384 33.472-126.92 84.684-162.968L347.48 264.268c-59.656 39.88-104.048 100.816-122.496 172.188 21.528 17.56 35.304 44.3 35.304 74.272 0 29.956-13.776 56.696-35.304 74.26C243.408 656.376 287.8 717.32 347.48 757.2l49.852-83.52c-51.212-36.028-84.684-95.56-84.684-162.948z m199.168-199.188c104.052 0 189.42 79.776 198.38 181.52l97.16-1.432c-4.776-75.112-37.592-142.544-88.008-192.128-25.928 9.796-55.88 8.296-81.76-6.624-25.932-14.964-42.192-40.208-46.636-67.608a297.04 297.04 0 0 0-79.14-10.76 295.148 295.148 0 0 0-131.276 30.652l47.38 84.908a198.384 198.384 0 0 1 83.9-18.528z m0 398.36a198.404 198.404 0 0 1-83.896-18.528l-47.38 84.9a294.848 294.848 0 0 0 131.28 30.684 296.16 296.16 0 0 0 79.136-10.788c4.444-27.4 20.708-52.62 46.632-67.608 25.904-14.948 55.836-16.42 81.76-6.624 50.42-49.584 83.232-117.016 88.016-192.128l-97.188-1.432c-8.94 101.772-94.304 181.52-198.36 181.52z m139.552-440.924c32.616 18.832 74.3 7.68 93.116-24.936 18.84-32.616 7.68-74.3-24.936-93.14-32.616-18.816-74.296-7.64-93.14 24.976-18.812 32.6-7.632 74.28 24.96 93.1z" fill="#FFF"/></svg>
if (id.startsWith('alpine')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M255.914667 68.565333L0 512l255.914667 443.434667h512.170666L1024 512 768.085333 68.565333H255.914667zM425.173333 303.786667L540.16 422.4l68.181333 68.053333 0.085334-0.085333 102.826666 100.821333c-8.533333 5.973333-16.469333 10.752-24.021333 14.677334a160.256 160.256 0 0 1-21.162667 9.258666 115.285333 115.285333 0 0 1-18.133333 4.736c-5.589333 0.981333-10.666667 1.450667-15.274667 1.450667-5.546667 0-10.325333-0.597333-14.421333-1.450667a56.192 56.192 0 0 1-10.24-3.072 40.533333 40.533333 0 0 1-8.533333-4.821333l-45.312-46.592-129.664-129.749333-46.933334 44.928-130.986666 131.072a41.557333 41.557333 0 0 1-8.533334 4.736 54.357333 54.357333 0 0 1-10.112 3.114666 70.826667 70.826667 0 0 1-14.421333 1.408c-4.608 0-9.685333-0.384-15.274667-1.322666a115.2 115.2 0 0 1-18.133333-4.864 159.914667 159.914667 0 0 1-21.162667-9.258667 223.061333 223.061333 0 0 1-24.021333-14.634667L425.173333 303.786667z m201.386667 33.493333l195.370667 196.181333 58.965333 57.728a223.573333 223.573333 0 0 1-24.064 14.677334 159.146667 159.146667 0 0 1-21.077333 9.258666 115.072 115.072 0 0 1-18.176 4.736c-5.546667 0.981333-10.709333 1.450667-15.36 1.450667-5.504 0-10.282667-0.597333-14.378667-1.450667a54.826667 54.826667 0 0 1-16.426667-6.229333 10.197333 10.197333 0 0 1-2.261333-1.706667l-52.565333-51.968-90.069334-90.069333-14.250666 14.250667L545.706667 418.133333l80.896-80.938666z m-254.549333 175.786667v107.904a90.794667 90.794667 0 0 1-15.189334-1.493334 117.973333 117.973333 0 0 1-18.005333-4.949333 158.208 158.208 0 0 1-20.821333-9.130667 222.592 222.592 0 0 1-23.68-14.506666l77.653333-77.866667z" fill="#0D597F"/></svg> if (id.startsWith('alpine')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M255.914667 68.565333L0 512l255.914667 443.434667h512.170666L1024 512 768.085333 68.565333H255.914667zM425.173333 303.786667L540.16 422.4l68.181333 68.053333 0.085334-0.085333 102.826666 100.821333c-8.533333 5.973333-16.469333 10.752-24.021333 14.677334a160.256 160.256 0 0 1-21.162667 9.258666 115.285333 115.285333 0 0 1-18.133333 4.736c-5.589333 0.981333-10.666667 1.450667-15.274667 1.450667-5.546667 0-10.325333-0.597333-14.421333-1.450667a56.192 56.192 0 0 1-10.24-3.072 40.533333 40.533333 0 0 1-8.533333-4.821333l-45.312-46.592-129.664-129.749333-46.933334 44.928-130.986666 131.072a41.557333 41.557333 0 0 1-8.533334 4.736 54.357333 54.357333 0 0 1-10.112 3.114666 70.826667 70.826667 0 0 1-14.421333 1.408c-4.608 0-9.685333-0.384-15.274667-1.322666a115.2 115.2 0 0 1-18.133333-4.864 159.914667 159.914667 0 0 1-21.162667-9.258667 223.061333 223.061333 0 0 1-24.021333-14.634667L425.173333 303.786667z m201.386667 33.493333l195.370667 196.181333 58.965333 57.728a223.573333 223.573333 0 0 1-24.064 14.677334 159.146667 159.146667 0 0 1-21.077333 9.258666 115.072 115.072 0 0 1-18.176 4.736c-5.546667 0.981333-10.709333 1.450667-15.36 1.450667-5.504 0-10.282667-0.597333-14.378667-1.450667a54.826667 54.826667 0 0 1-16.426667-6.229333 10.197333 10.197333 0 0 1-2.261333-1.706667l-52.565333-51.968-90.069334-90.069333-14.250666 14.250667L545.706667 418.133333l80.896-80.938666z m-254.549333 175.786667v107.904a90.794667 90.794667 0 0 1-15.189334-1.493334 117.973333 117.973333 0 0 1-18.005333-4.949333 158.208 158.208 0 0 1-20.821333-9.130667 222.592 222.592 0 0 1-23.68-14.506666l77.653333-77.866667z" fill="#0D597F"/></svg>
@@ -916,6 +977,7 @@ function getTemplateIcon(id: string): ReactNode {
if (id.startsWith('archlinux')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M504.149333 7.850667c-44.373333 108.544-70.997333 179.2-120.149333 284.330666 30.037333 32.085333 67.242667 69.290667 127.317333 111.274667-64.512-26.624-108.544-53.248-141.653333-80.896-63.146667 131.413333-161.792 318.464-361.813333 678.229333 157.696-90.794667 279.552-146.773333 393.216-168.277333-4.778667-21.162667-7.509333-43.690667-7.509334-67.584l0.341334-5.12c2.389333-100.693333 54.954667-178.517333 117.077333-173.056s110.592 91.477333 107.861333 192.170667c-0.341333 18.090667-2.389333 36.522667-6.485333 54.272 112.64 21.845333 233.130667 77.824 388.437333 167.594666l-83.968-155.648c-40.96-31.744-83.968-73.386667-171.349333-118.101333 60.074667 15.701333 103.082667 33.792 136.533333 53.930667-265.557333-493.909333-287.061333-559.786667-377.856-773.12z" fill="#1793D1"/></svg> if (id.startsWith('archlinux')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M504.149333 7.850667c-44.373333 108.544-70.997333 179.2-120.149333 284.330666 30.037333 32.085333 67.242667 69.290667 127.317333 111.274667-64.512-26.624-108.544-53.248-141.653333-80.896-63.146667 131.413333-161.792 318.464-361.813333 678.229333 157.696-90.794667 279.552-146.773333 393.216-168.277333-4.778667-21.162667-7.509333-43.690667-7.509334-67.584l0.341334-5.12c2.389333-100.693333 54.954667-178.517333 117.077333-173.056s110.592 91.477333 107.861333 192.170667c-0.341333 18.090667-2.389333 36.522667-6.485333 54.272 112.64 21.845333 233.130667 77.824 388.437333 167.594666l-83.968-155.648c-40.96-31.744-83.968-73.386667-171.349333-118.101333 60.074667 15.701333 103.082667 33.792 136.533333 53.930667-265.557333-493.909333-287.061333-559.786667-377.856-773.12z" fill="#1793D1"/></svg>
if (id.startsWith('fedora')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M512 0C229.344 0 0.224 229.024 0 511.648V907.84a116.384 116.384 0 0 0 116.384 116.128h395.808c282.656-0.128 511.776-229.28 511.776-512 0-282.752-229.248-512-512-512z m196.064 237.952c-16.16 0-22.016-3.104-45.728-3.104a126.848 126.848 0 0 0-126.848 126.624v110.208c0 9.888 8.032 17.92 17.92 17.92h83.328c31.072 0 56.16 24.736 56.16 55.904 0 31.328-25.344 55.968-56.736 55.968h-100.608v127.36a240.32 240.32 0 0 1-240.288 240.288h-1.248a190.944 190.944 0 0 1-53.216-7.52l1.344 0.32c-27.168-7.072-49.376-29.408-49.376-55.296 0-31.328 22.752-54.112 56.736-54.112 16.128 0 22.016 3.072 45.696 3.072a126.848 126.848 0 0 0 126.848-126.624v-110.208a17.92 17.92 0 0 0-17.92-17.888h-83.328a55.808 55.808 0 0 1-56.096-55.904c0-31.328 25.344-55.968 56.736-55.968h100.576v-127.36a240.32 240.32 0 0 1 240.288-240.288c20.128 0 34.432 2.272 53.088 7.136 27.168 7.136 49.408 29.44 49.408 55.296 0 31.36-22.752 54.144-56.736 54.144z" fill="#294172"/></svg> if (id.startsWith('fedora')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M512 0C229.344 0 0.224 229.024 0 511.648V907.84a116.384 116.384 0 0 0 116.384 116.128h395.808c282.656-0.128 511.776-229.28 511.776-512 0-282.752-229.248-512-512-512z m196.064 237.952c-16.16 0-22.016-3.104-45.728-3.104a126.848 126.848 0 0 0-126.848 126.624v110.208c0 9.888 8.032 17.92 17.92 17.92h83.328c31.072 0 56.16 24.736 56.16 55.904 0 31.328-25.344 55.968-56.736 55.968h-100.608v127.36a240.32 240.32 0 0 1-240.288 240.288h-1.248a190.944 190.944 0 0 1-53.216-7.52l1.344 0.32c-27.168-7.072-49.376-29.408-49.376-55.296 0-31.328 22.752-54.112 56.736-54.112 16.128 0 22.016 3.072 45.696 3.072a126.848 126.848 0 0 0 126.848-126.624v-110.208a17.92 17.92 0 0 0-17.92-17.888h-83.328a55.808 55.808 0 0 1-56.096-55.904c0-31.328 25.344-55.968 56.736-55.968h100.576v-127.36a240.32 240.32 0 0 1 240.288-240.288c20.128 0 34.432 2.272 53.088 7.136 27.168 7.136 49.408 29.44 49.408 55.296 0 31.36-22.752 54.144-56.736 54.144z" fill="#294172"/></svg>
if (id.startsWith('rockylinux')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M995.498667 680.362667c18.474667-52.778667 28.501333-109.568 28.501333-168.704C1024 229.077333 794.752 0 512 0S0 229.077333 0 511.658667c0 139.818667 56.106667 266.496 147.114667 358.826666L666.453333 351.530667l128.213334 128.170666 200.832 200.704z m-93.525334 162.816l-235.52-235.349334-368.896 368.597334A510.506667 510.506667 0 0 0 512 1023.274667c156.16 0 296.106667-69.888 389.973333-180.053334h0.042667z" fill="#10B981"/></svg> if (id.startsWith('rockylinux')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M995.498667 680.362667c18.474667-52.778667 28.501333-109.568 28.501333-168.704C1024 229.077333 794.752 0 512 0S0 229.077333 0 511.658667c0 139.818667 56.106667 266.496 147.114667 358.826666L666.453333 351.530667l128.213334 128.170666 200.832 200.704z m-93.525334 162.816l-235.52-235.349334-368.896 368.597334A510.506667 510.506667 0 0 0 512 1023.274667c156.16 0 296.106667-69.888 389.973333-180.053334h0.042667z" fill="#10B981"/></svg>
if (id.startsWith('windows')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M56.888889 227.555556l398.222222-70.542223V512H56.888889V227.555556z m0 625.777777l398.222222 70.542223V568.888889H56.888889v284.444444zM512 147.342222L1024 56.888889v455.111111H512V147.342222z m0 786.204445L1024 1024v-455.111111H512v364.657778z" fill="#16C6FE"/></svg>
return null return null
} }
+1 -2
View File
@@ -119,8 +119,7 @@ export default function Dashboard() {
ramPercent={host.ram.total_mb > 0 ? (host.ram.used_mb / host.ram.total_mb) * 100 : 0} ramPercent={host.ram.total_mb > 0 ? (host.ram.used_mb / host.ram.total_mb) * 100 : 0}
ramUsed={host.ram.used_mb} ramUsed={host.ram.used_mb}
ramTotal={host.ram.total_mb} ramTotal={host.ram.total_mb}
loadPercent={Math.min((host.load.load1 / host.cpu.cores) * 100, 100)} loadPercent={(host.load.load1 / Math.max(host.cpu.cores, 1)) * 100}
loadStatus={host.load.load1 < host.cpu.cores * 0.7 ? '正常' : host.load.load1 < host.cpu.cores * 1.0 ? '中等' : '高'}
diskPercent={host.disk.total_gb > 0 ? (host.disk.used_gb / host.disk.total_gb) * 100 : 0} diskPercent={host.disk.total_gb > 0 ? (host.disk.used_gb / host.disk.total_gb) * 100 : 0}
diskUsed={host.disk.used_gb * 1024} diskUsed={host.disk.used_gb * 1024}
diskTotal={host.disk.total_gb * 1024} diskTotal={host.disk.total_gb * 1024}
+153 -19
View File
@@ -9,10 +9,13 @@ import {
ToggleRight, ToggleRight,
Loader2, Loader2,
AlertCircle, AlertCircle,
X,
} from 'lucide-react' } from 'lucide-react'
import { getImages, downloadImage, deleteImage, toggleImage, ImageInfo } from '../services/api' import { getImages, downloadImage, cancelImageDownload, deleteImage, toggleImage, ImageInfo } from '../services/api'
import { useDialog } from '../components/Dialog'
export default function ImageManagement() { export default function ImageManagement() {
const dialog = useDialog()
const [images, setImages] = useState<ImageInfo[]>([]) const [images, setImages] = useState<ImageInfo[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [actionLoading, setActionLoading] = useState<string | null>(null) const [actionLoading, setActionLoading] = useState<string | null>(null)
@@ -32,10 +35,14 @@ export default function ImageManagement() {
useEffect(() => { useEffect(() => {
fetchImages() fetchImages()
const interval = setInterval(fetchImages, 5000)
return () => clearInterval(interval)
}, [fetchImages]) }, [fetchImages])
useEffect(() => {
const hasDownloads = images.some((img) => img.downloading)
const interval = setInterval(fetchImages, hasDownloads ? 1500 : 5000)
return () => clearInterval(interval)
}, [fetchImages, images])
const handleDownload = async (templateId: string) => { const handleDownload = async (templateId: string) => {
setActionLoading(templateId) setActionLoading(templateId)
setError('') setError('')
@@ -43,15 +50,27 @@ export default function ImageManagement() {
await downloadImage(templateId) await downloadImage(templateId)
await fetchImages() await fetchImages()
} catch (err: unknown) { } catch (err: unknown) {
const msg = err instanceof Error ? err.message : '下载失败' setError(apiErrorMessage(err, '下载失败'))
setError(msg) } finally {
setActionLoading(null)
}
}
const handleCancelDownload = async (templateId: string) => {
setActionLoading(templateId)
setError('')
try {
await cancelImageDownload(templateId)
await fetchImages()
} catch (err: unknown) {
setError(apiErrorMessage(err, '取消失败'))
} finally { } finally {
setActionLoading(null) setActionLoading(null)
} }
} }
const handleDelete = async (templateId: string) => { const handleDelete = async (templateId: string) => {
if (!window.confirm('确定要删除该镜像缓存吗?删除后需要重新下载才能使用。')) return if (!(await dialog.confirm('删除镜像', '确定要删除该镜像缓存吗?删除后需要重新下载才能使用。'))) return
setActionLoading(templateId) setActionLoading(templateId)
setError('') setError('')
try { try {
@@ -80,6 +99,8 @@ export default function ImageManagement() {
} }
const downloadedCount = images.filter((img) => img.downloaded).length const downloadedCount = images.filter((img) => img.downloaded).length
const lxcImages = images.filter((img) => img.type === 'lxc')
const kvmImages = images.filter((img) => img.type === 'kvm')
if (loading) { if (loading) {
return ( return (
@@ -95,7 +116,7 @@ export default function ImageManagement() {
<div> <div>
<h1 className="text-2xl font-bold text-black"></h1> <h1 className="text-2xl font-bold text-black"></h1>
<p className="text-sm text-gray-500 mt-1"> <p className="text-sm text-gray-500 mt-1">
LXC LXC / KVM /
{downloadedCount}/{images.length} {downloadedCount}/{images.length}
</p> </p>
</div> </div>
@@ -115,6 +136,62 @@ export default function ImageManagement() {
</div> </div>
)} )}
<ImageTable
title="LXC 容器镜像"
images={lxcImages}
actionLoading={actionLoading}
downloadedCount={lxcImages.filter((img) => img.downloaded).length}
totalCount={lxcImages.length}
onDownload={handleDownload}
onCancelDownload={handleCancelDownload}
onDelete={handleDelete}
onToggle={handleToggle}
/>
<ImageTable
title="KVM 虚拟机镜像"
images={kvmImages}
actionLoading={actionLoading}
downloadedCount={kvmImages.filter((img) => img.downloaded).length}
totalCount={kvmImages.length}
onDownload={handleDownload}
onCancelDownload={handleCancelDownload}
onDelete={handleDelete}
onToggle={handleToggle}
/>
</div>
)
}
function ImageTable({
title,
images,
actionLoading,
downloadedCount,
totalCount,
onDownload,
onCancelDownload,
onDelete,
onToggle,
}: {
title: string
images: ImageInfo[]
actionLoading: string | null
downloadedCount: number
totalCount: number
onDownload: (id: string) => void
onCancelDownload: (id: string) => void
onDelete: (id: string) => void
onToggle: (id: string, enabled: boolean) => void
}) {
return (
<div className="space-y-3">
<div className="flex items-center gap-3">
<h2 className="text-lg font-semibold text-gray-800">{title}</h2>
<span className="text-xs text-gray-400">
{downloadedCount}/{totalCount}
</span>
</div>
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden"> <div className="bg-white border border-gray-200 rounded-lg overflow-hidden">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full"> <table className="w-full">
@@ -147,12 +224,13 @@ export default function ImageManagement() {
<tr key={img.id} className="hover:bg-gray-50 transition-colors"> <tr key={img.id} className="hover:bg-gray-50 transition-colors">
<td className="px-4 py-3"> <td className="px-4 py-3">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="w-8 h-8 bg-gray-100 rounded-lg flex items-center justify-center flex-shrink-0"> <span className="w-8 h-8 flex items-center justify-center flex-shrink-0">
{getTemplateIcon(img.id)} {getTemplateIcon(img.id)}
</span> </span>
<div> <div>
<span className="font-medium text-gray-900 text-sm">{img.name}</span> <span className="font-medium text-gray-900 text-sm">{img.name}</span>
<p className="text-[11px] text-gray-400">{img.description}</p> <p className="text-[11px] text-gray-400">{img.description}</p>
</div> </div>
</div> </div>
</td> </td>
@@ -172,7 +250,7 @@ export default function ImageManagement() {
<div className="flex items-center justify-end gap-2"> <div className="flex items-center justify-end gap-2">
{!img.downloaded && !img.downloading && ( {!img.downloaded && !img.downloading && (
<button <button
onClick={() => handleDownload(img.id)} onClick={() => onDownload(img.id)}
disabled={isBusy} disabled={isBusy}
className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-black text-white rounded-md hover:bg-gray-800 transition-colors text-xs font-medium disabled:opacity-50" className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-black text-white rounded-md hover:bg-gray-800 transition-colors text-xs font-medium disabled:opacity-50"
> >
@@ -186,16 +264,21 @@ export default function ImageManagement() {
)} )}
{img.downloading && ( {img.downloading && (
<span className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-amber-50 border border-amber-200 rounded-md text-amber-700 text-xs font-medium"> <button
<Loader2 className="w-3.5 h-3.5 animate-spin" /> onClick={() => onCancelDownload(img.id)}
... disabled={isBusy}
</span> className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md border border-red-200 text-red-600 hover:bg-red-50 transition-colors text-xs font-medium disabled:opacity-50"
title="取消下载并清理临时文件"
>
{isBusy ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <X className="w-3.5 h-3.5" />}
{isBusy ? '取消中...' : '取消'}
</button>
)} )}
{img.downloaded && ( {img.downloaded && !img.downloading && (
<> <>
<button <button
onClick={() => handleToggle(img.id, img.enabled)} onClick={() => onToggle(img.id, img.enabled)}
disabled={isBusy} disabled={isBusy}
className={`inline-flex items-center gap-1 px-2.5 py-1.5 rounded-md text-xs font-medium transition-colors disabled:opacity-50 ${ className={`inline-flex items-center gap-1 px-2.5 py-1.5 rounded-md text-xs font-medium transition-colors disabled:opacity-50 ${
img.enabled img.enabled
@@ -207,7 +290,7 @@ export default function ImageManagement() {
{img.enabled ? '启用' : '禁用'} {img.enabled ? '启用' : '禁用'}
</button> </button>
<button <button
onClick={() => handleDelete(img.id)} onClick={() => onDelete(img.id)}
disabled={isBusy} disabled={isBusy}
className="inline-flex items-center gap-1 px-2.5 py-1.5 rounded-md border border-red-200 text-red-600 hover:bg-red-50 transition-colors text-xs font-medium disabled:opacity-50" className="inline-flex items-center gap-1 px-2.5 py-1.5 rounded-md border border-red-200 text-red-600 hover:bg-red-50 transition-colors text-xs font-medium disabled:opacity-50"
title="删除镜像缓存" title="删除镜像缓存"
@@ -231,10 +314,33 @@ export default function ImageManagement() {
function StatusBadge({ img }: { img: ImageInfo }) { function StatusBadge({ img }: { img: ImageInfo }) {
if (img.downloading) { if (img.downloading) {
const progress = Math.max(0, Math.min(100, img.progress || 0))
const showProgress = img.stage === 'downloading' && progress > 0
return ( return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium bg-amber-50 text-amber-700"> <div className="inline-flex flex-col gap-1">
<span className="w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse" /> <span
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium bg-amber-50 text-amber-700"
title={downloadStatusTitle(img)}
>
<span className="w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse" />
{downloadStatusLabel(img)}
</span>
{showProgress && (
<span className="block h-1 w-24 overflow-hidden rounded-full bg-amber-100">
<span className="block h-full rounded-full bg-amber-500 transition-all" style={{ width: `${progress}%` }} />
</span>
)}
</div>
)
}
if (img.error) {
return (
<span
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium bg-red-50 text-red-600"
title={img.error}
>
<AlertCircle className="w-3 h-3" />
</span> </span>
) )
} }
@@ -262,8 +368,30 @@ function StatusBadge({ img }: { img: ImageInfo }) {
) )
} }
function downloadStatusLabel(img: ImageInfo) {
if (img.stage === 'canceling') return '取消中'
if (img.stage === 'converting') return '转换中'
if (img.stage === 'lxc-create') return '下载中'
if (img.progress > 0) return `下载中 ${Math.min(100, img.progress)}%`
return '下载中'
}
function downloadStatusTitle(img: ImageInfo) {
const parts = [downloadStatusLabel(img)]
if (img.stage) parts.push(`阶段:${img.stage}`)
if (img.downloaded_bytes > 0 || img.total_bytes > 0) {
parts.push(`${formatSize(img.downloaded_bytes)} / ${formatSize(img.total_bytes)}`)
}
return parts.join('')
}
function isWindowsImage(img: ImageInfo) {
return img.distro === 'windows' || img.id.toLowerCase().includes('windows')
}
function getTemplateIcon(id: string): ReactNode { function getTemplateIcon(id: string): ReactNode {
const size = 'w-5 h-5' const size = 'w-5 h-5'
id = id.startsWith('kvm-') ? id.slice(4) : id
if (id.startsWith('debian')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M935.473 375.359a558.602 558.602 0 0 0-22.351-114.655l13.308 4.436c-35.66-81.385-90.086-163.623-153.556-199.282-8.701-5.118-35.147 4.948-26.616-12.113s-37.536-8.19-56.816-4.778c-26.275 4.266-30.028-29.175-75.071-35.83-25.593-3.582-32.247 18.427-44.702 13.309-23.545-9.384-20.816-27.64-57.669-9.384-18.427 9.042 11.602-26.105-49.138-4.607L457.744 0C349.23 41.63 318.69 76.266 288.15 79.337c-6.996 0-34.124 32.759-53.574 53.062-17.062 17.062-26.275 36.512-49.138 39.583l-17.062 70.636A136.494 136.494 0 0 0 119.41 339.7a66.711 66.711 0 0 1 4.436-52.892c-17.062 6.825-45.896 17.062-29.687 96.91 12.796 63.13-5.29 135.13 10.066 204.742 4.777 20.986 0 40.095 6.142 51.185 107.66 235.794 208.836 392.08 472.44 384.06l4.436-8.872c-28.152-6.825-55.11-17.062-111.584-30.711-18.597-4.436-23.033-34.124-40.265-44.19-9.384-5.46-28.323-4.095-37.195-9.896s4.266-21.668-19.962-14.332c-8.531 2.56-13.82-10.92-20.133-17.061s0-23.716-23.375-24.74-18.426-29.687-19.791-44.702c-12.114 1.536-1.195-1.535-13.308 4.436a63.64 63.64 0 0 1-23.887-31.735c-10.237-48.967-10.578-21.497-15.014-32.417a322.297 322.297 0 0 0-19.28-42.142l26.787 8.872h4.436l4.436-13.309-26.616-8.701h31.223c-7.678 13.99 2.047 5.29-13.479 8.872v13.308l22.35-8.872v-13.308c-20.644-10.237-28.663-13.308-49.137-22.01l9.043 8.872v4.436h-49.138c-22.01-14.843-13.99-31.734-17.915-53.062 17.062 0 9.213 6.655 17.062-13.137l-17.062 8.872 13.308-33.953-13.308 13.138c-29.176-38.73-16.209-97.764-11.943-152.02A180.684 180.684 0 0 1 211.2 372.97c8.872-10.067 5.119-25.251 5.46-37.195l31.223-26.445H265.8c7.678 17.061 4.777 5.46 0 22.01l8.872 4.435c7.166-8.701 6.142-5.971 8.872-22.01-10.066-10.578-6.995-9.895-26.616-13.308A119.432 119.432 0 0 1 368.51 243.13l4.436-13.308-17.915 9.043-4.436-13.138a109.536 109.536 0 0 1 76.095-27.128c6.313 0 6.996-17.062 12.797-19.45 161.574-60.57 309.33 9.383 371.093 147.413a324.173 324.173 0 0 1 8.19 34.123c17.061 56.987-7.167 121.48 9.725 155.604-7.849 36-36.683 13.82-40.266 30.881-8.531 41.29-14.844 59.717-40.778 78.826a196.38 196.38 0 0 1-30.711 22.35 84.285 84.285 0 0 0 22.35-39.753c-106.294 111.584-262.58 63.981-290.049-105.954a101.176 101.176 0 0 1 35.147-93.157c92.987-87.527 150.144-52.38 205.765-20.474l-8.872-30.711c-32.93-24.398-17.062-19.792-9.043-57.328v-4.436l-17.915-13.137c2.56 10.066 1.024 5.289 9.043 17.061-4.436 16.039 0 9.043-8.872 17.062-15.014 9.725-23.716 7.337-44.702 4.436l4.436-13.308-13.308-13.308c0 11.773-4.095 2.73 0 17.062-126.086 9.896-218.05 80.02-178.636 260.191a220.608 220.608 0 0 0 8.872 44.19l-8.872 8.702-4.436-26.446h-13.48l-4.435 13.308c-12.626-25.763-0.853 10.75 40.265 52.892a149.29 149.29 0 0 0 12.797 12.625c47.773 34.124 113.29 81.385 201.328 49.138h9.043v-4.436l-102.37-13.308-4.436-8.701c106.806 24.74 176.93-8.531 236.646-48.456 13.138-17.062 11.431-24.057 22.18-9.043 19.28-17.061 3.925-26.786 13.48-44.019 6.483-11.772 32.587-17.062 44.7-35.318l40.096-136.494h-17.062c3.071-14.332 22.522-34.123-4.436-48.455-2.559-1.536 9.043-1.365 8.872-4.266a145.537 145.537 0 0 0-22.18-66.37c33.1 21.669 36.342 68.247 53.574 105.783v8.872h4.436V375.36zM453.308 595.455l-9.555-26.446 62.446 57.328zM146.196 211.736l-23.204-4.436v39.754c16.72-10.578 18.939-10.407 22.35-35.318z m574.981 176.419a57.498 57.498 0 0 0-17.062 44.19l13.48 8.701a37.877 37.877 0 0 0 4.435-52.891zM868.42 555.872c26.275-11.602 54.598-58.01 35.83-97.081l-35.83 96.91z m-174.03-79.508c-15.697 11.773-19.791 13.308-22.35 39.754l13.307 8.872 17.915-8.872a60.228 60.228 0 0 0 4.436-48.455c-8.36 13.478-2.559 20.644-13.308 8.701z m-67.053 79.508c15.868-10.92 11.944-14.844 17.915-22.18v-4.778a292.097 292.097 0 0 1-62.446 0c-13.137-13.99-13.308-29.346-31.223-39.583 17.062 35.147 3.242 38.218 31.223 61.764a158.162 158.162 0 0 0 40.095 4.436c1.536 0-6.824-1.024 4.436 0zM207.79 520.554H194.31l-8.872 8.702c9.555 10.237 5.46 7.166 13.308-4.436L212.225 547l4.436-17.062-8.872-8.701z m17.062 57.328l4.436-8.873c-10.067-8.701 0-3.583-13.308 0l-13.309-17.061 4.436 17.061v8.873h17.062z" fill="#CE0C48"/></svg> if (id.startsWith('debian')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M935.473 375.359a558.602 558.602 0 0 0-22.351-114.655l13.308 4.436c-35.66-81.385-90.086-163.623-153.556-199.282-8.701-5.118-35.147 4.948-26.616-12.113s-37.536-8.19-56.816-4.778c-26.275 4.266-30.028-29.175-75.071-35.83-25.593-3.582-32.247 18.427-44.702 13.309-23.545-9.384-20.816-27.64-57.669-9.384-18.427 9.042 11.602-26.105-49.138-4.607L457.744 0C349.23 41.63 318.69 76.266 288.15 79.337c-6.996 0-34.124 32.759-53.574 53.062-17.062 17.062-26.275 36.512-49.138 39.583l-17.062 70.636A136.494 136.494 0 0 0 119.41 339.7a66.711 66.711 0 0 1 4.436-52.892c-17.062 6.825-45.896 17.062-29.687 96.91 12.796 63.13-5.29 135.13 10.066 204.742 4.777 20.986 0 40.095 6.142 51.185 107.66 235.794 208.836 392.08 472.44 384.06l4.436-8.872c-28.152-6.825-55.11-17.062-111.584-30.711-18.597-4.436-23.033-34.124-40.265-44.19-9.384-5.46-28.323-4.095-37.195-9.896s4.266-21.668-19.962-14.332c-8.531 2.56-13.82-10.92-20.133-17.061s0-23.716-23.375-24.74-18.426-29.687-19.791-44.702c-12.114 1.536-1.195-1.535-13.308 4.436a63.64 63.64 0 0 1-23.887-31.735c-10.237-48.967-10.578-21.497-15.014-32.417a322.297 322.297 0 0 0-19.28-42.142l26.787 8.872h4.436l4.436-13.309-26.616-8.701h31.223c-7.678 13.99 2.047 5.29-13.479 8.872v13.308l22.35-8.872v-13.308c-20.644-10.237-28.663-13.308-49.137-22.01l9.043 8.872v4.436h-49.138c-22.01-14.843-13.99-31.734-17.915-53.062 17.062 0 9.213 6.655 17.062-13.137l-17.062 8.872 13.308-33.953-13.308 13.138c-29.176-38.73-16.209-97.764-11.943-152.02A180.684 180.684 0 0 1 211.2 372.97c8.872-10.067 5.119-25.251 5.46-37.195l31.223-26.445H265.8c7.678 17.061 4.777 5.46 0 22.01l8.872 4.435c7.166-8.701 6.142-5.971 8.872-22.01-10.066-10.578-6.995-9.895-26.616-13.308A119.432 119.432 0 0 1 368.51 243.13l4.436-13.308-17.915 9.043-4.436-13.138a109.536 109.536 0 0 1 76.095-27.128c6.313 0 6.996-17.062 12.797-19.45 161.574-60.57 309.33 9.383 371.093 147.413a324.173 324.173 0 0 1 8.19 34.123c17.061 56.987-7.167 121.48 9.725 155.604-7.849 36-36.683 13.82-40.266 30.881-8.531 41.29-14.844 59.717-40.778 78.826a196.38 196.38 0 0 1-30.711 22.35 84.285 84.285 0 0 0 22.35-39.753c-106.294 111.584-262.58 63.981-290.049-105.954a101.176 101.176 0 0 1 35.147-93.157c92.987-87.527 150.144-52.38 205.765-20.474l-8.872-30.711c-32.93-24.398-17.062-19.792-9.043-57.328v-4.436l-17.915-13.137c2.56 10.066 1.024 5.289 9.043 17.061-4.436 16.039 0 9.043-8.872 17.062-15.014 9.725-23.716 7.337-44.702 4.436l4.436-13.308-13.308-13.308c0 11.773-4.095 2.73 0 17.062-126.086 9.896-218.05 80.02-178.636 260.191a220.608 220.608 0 0 0 8.872 44.19l-8.872 8.702-4.436-26.446h-13.48l-4.435 13.308c-12.626-25.763-0.853 10.75 40.265 52.892a149.29 149.29 0 0 0 12.797 12.625c47.773 34.124 113.29 81.385 201.328 49.138h9.043v-4.436l-102.37-13.308-4.436-8.701c106.806 24.74 176.93-8.531 236.646-48.456 13.138-17.062 11.431-24.057 22.18-9.043 19.28-17.061 3.925-26.786 13.48-44.019 6.483-11.772 32.587-17.062 44.7-35.318l40.096-136.494h-17.062c3.071-14.332 22.522-34.123-4.436-48.455-2.559-1.536 9.043-1.365 8.872-4.266a145.537 145.537 0 0 0-22.18-66.37c33.1 21.669 36.342 68.247 53.574 105.783v8.872h4.436V375.36zM453.308 595.455l-9.555-26.446 62.446 57.328zM146.196 211.736l-23.204-4.436v39.754c16.72-10.578 18.939-10.407 22.35-35.318z m574.981 176.419a57.498 57.498 0 0 0-17.062 44.19l13.48 8.701a37.877 37.877 0 0 0 4.435-52.891zM868.42 555.872c26.275-11.602 54.598-58.01 35.83-97.081l-35.83 96.91z m-174.03-79.508c-15.697 11.773-19.791 13.308-22.35 39.754l13.307 8.872 17.915-8.872a60.228 60.228 0 0 0 4.436-48.455c-8.36 13.478-2.559 20.644-13.308 8.701z m-67.053 79.508c15.868-10.92 11.944-14.844 17.915-22.18v-4.778a292.097 292.097 0 0 1-62.446 0c-13.137-13.99-13.308-29.346-31.223-39.583 17.062 35.147 3.242 38.218 31.223 61.764a158.162 158.162 0 0 0 40.095 4.436c1.536 0-6.824-1.024 4.436 0zM207.79 520.554H194.31l-8.872 8.702c9.555 10.237 5.46 7.166 13.308-4.436L212.225 547l4.436-17.062-8.872-8.701z m17.062 57.328l4.436-8.873c-10.067-8.701 0-3.583-13.308 0l-13.309-17.061 4.436 17.061v8.873h17.062z" fill="#CE0C48"/></svg>
if (id.startsWith('ubuntu')) return <svg className={size} viewBox="0 0 1024 1024"><circle cx="512" cy="512" r="511" fill="#DD4814"/><path d="M164.532 442.532c-37.676 0-68.2 30.524-68.2 68.2 0 37.656 30.524 68.184 68.2 68.184 37.66 0 68.184-30.528 68.184-68.184 0-37.676-30.524-68.2-68.184-68.2z m486.86 309.912c-32.612 18.84-43.8 60.52-24.96 93.116 18.82 32.616 60.5 43.796 93.116 24.96 32.612-18.82 43.796-60.5 24.96-93.12-18.82-32.592-60.524-43.772-93.116-24.956z m-338.744-241.712c0-67.384 33.472-126.92 84.684-162.968L347.48 264.268c-59.656 39.88-104.048 100.816-122.496 172.188 21.528 17.56 35.304 44.3 35.304 74.272 0 29.956-13.776 56.696-35.304 74.26C243.408 656.376 287.8 717.32 347.48 757.2l49.852-83.52c-51.212-36.028-84.684-95.56-84.684-162.948z m199.168-199.188c104.052 0 189.42 79.776 198.38 181.52l97.16-1.432c-4.776-75.112-37.592-142.544-88.008-192.128-25.928 9.796-55.88 8.296-81.76-6.624-25.932-14.964-42.192-40.208-46.636-67.608a297.04 297.04 0 0 0-79.14-10.76 295.148 295.148 0 0 0-131.276 30.652l47.38 84.908a198.384 198.384 0 0 1 83.9-18.528z m0 398.36a198.404 198.404 0 0 1-83.896-18.528l-47.38 84.9a294.848 294.848 0 0 0 131.28 30.684 296.16 296.16 0 0 0 79.136-10.788c4.444-27.4 20.708-52.62 46.632-67.608 25.904-14.948 55.836-16.42 81.76-6.624 50.42-49.584 83.232-117.016 88.016-192.128l-97.188-1.432c-8.94 101.772-94.304 181.52-198.36 181.52z m139.552-440.924c32.616 18.832 74.3 7.68 93.116-24.936 18.84-32.616 7.68-74.3-24.936-93.14-32.616-18.816-74.296-7.64-93.14 24.976-18.812 32.6-7.632 74.28 24.96 93.1z" fill="#FFF"/></svg> if (id.startsWith('ubuntu')) return <svg className={size} viewBox="0 0 1024 1024"><circle cx="512" cy="512" r="511" fill="#DD4814"/><path d="M164.532 442.532c-37.676 0-68.2 30.524-68.2 68.2 0 37.656 30.524 68.184 68.2 68.184 37.66 0 68.184-30.528 68.184-68.184 0-37.676-30.524-68.2-68.184-68.2z m486.86 309.912c-32.612 18.84-43.8 60.52-24.96 93.116 18.82 32.616 60.5 43.796 93.116 24.96 32.612-18.82 43.796-60.5 24.96-93.12-18.82-32.592-60.524-43.772-93.116-24.956z m-338.744-241.712c0-67.384 33.472-126.92 84.684-162.968L347.48 264.268c-59.656 39.88-104.048 100.816-122.496 172.188 21.528 17.56 35.304 44.3 35.304 74.272 0 29.956-13.776 56.696-35.304 74.26C243.408 656.376 287.8 717.32 347.48 757.2l49.852-83.52c-51.212-36.028-84.684-95.56-84.684-162.948z m199.168-199.188c104.052 0 189.42 79.776 198.38 181.52l97.16-1.432c-4.776-75.112-37.592-142.544-88.008-192.128-25.928 9.796-55.88 8.296-81.76-6.624-25.932-14.964-42.192-40.208-46.636-67.608a297.04 297.04 0 0 0-79.14-10.76 295.148 295.148 0 0 0-131.276 30.652l47.38 84.908a198.384 198.384 0 0 1 83.9-18.528z m0 398.36a198.404 198.404 0 0 1-83.896-18.528l-47.38 84.9a294.848 294.848 0 0 0 131.28 30.684 296.16 296.16 0 0 0 79.136-10.788c4.444-27.4 20.708-52.62 46.632-67.608 25.904-14.948 55.836-16.42 81.76-6.624 50.42-49.584 83.232-117.016 88.016-192.128l-97.188-1.432c-8.94 101.772-94.304 181.52-198.36 181.52z m139.552-440.924c32.616 18.832 74.3 7.68 93.116-24.936 18.84-32.616 7.68-74.3-24.936-93.14-32.616-18.816-74.296-7.64-93.14 24.976-18.812 32.6-7.632 74.28 24.96 93.1z" fill="#FFF"/></svg>
if (id.startsWith('alpine')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M255.914667 68.565333L0 512l255.914667 443.434667h512.170666L1024 512 768.085333 68.565333H255.914667zM425.173333 303.786667L540.16 422.4l68.181333 68.053333 0.085334-0.085333 102.826666 100.821333c-8.533333 5.973333-16.469333 10.752-24.021333 14.677334a160.256 160.256 0 0 1-21.162667 9.258666 115.285333 115.285333 0 0 1-18.133333 4.736c-5.589333 0.981333-10.666667 1.450667-15.274667 1.450667-5.546667 0-10.325333-0.597333-14.421333-1.450667a56.192 56.192 0 0 1-10.24-3.072 40.533333 40.533333 0 0 1-8.533333-4.821333l-45.312-46.592-129.664-129.749333-46.933334 44.928-130.986666 131.072a41.557333 41.557333 0 0 1-8.533334 4.736 54.357333 54.357333 0 0 1-10.112 3.114666 70.826667 70.826667 0 0 1-14.421333 1.408c-4.608 0-9.685333-0.384-15.274667-1.322666a115.2 115.2 0 0 1-18.133333-4.864 159.914667 159.914667 0 0 1-21.162667-9.258667 223.061333 223.061333 0 0 1-24.021333-14.634667L425.173333 303.786667z m201.386667 33.493333l195.370667 196.181333 58.965333 57.728a223.573333 223.573333 0 0 1-24.064 14.677334 159.146667 159.146667 0 0 1-21.077333 9.258666 115.072 115.072 0 0 1-18.176 4.736c-5.546667 0.981333-10.709333 1.450667-15.36 1.450667-5.504 0-10.282667-0.597333-14.378667-1.450667a54.826667 54.826667 0 0 1-16.426667-6.229333 10.197333 10.197333 0 0 1-2.261333-1.706667l-52.565333-51.968-90.069334-90.069333-14.250666 14.250667L545.706667 418.133333l80.896-80.938666z m-254.549333 175.786667v107.904a90.794667 90.794667 0 0 1-15.189334-1.493334 117.973333 117.973333 0 0 1-18.005333-4.949333 158.208 158.208 0 0 1-20.821333-9.130667 222.592 222.592 0 0 1-23.68-14.506666l77.653333-77.866667z" fill="#0D597F"/></svg> if (id.startsWith('alpine')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M255.914667 68.565333L0 512l255.914667 443.434667h512.170666L1024 512 768.085333 68.565333H255.914667zM425.173333 303.786667L540.16 422.4l68.181333 68.053333 0.085334-0.085333 102.826666 100.821333c-8.533333 5.973333-16.469333 10.752-24.021333 14.677334a160.256 160.256 0 0 1-21.162667 9.258666 115.285333 115.285333 0 0 1-18.133333 4.736c-5.589333 0.981333-10.666667 1.450667-15.274667 1.450667-5.546667 0-10.325333-0.597333-14.421333-1.450667a56.192 56.192 0 0 1-10.24-3.072 40.533333 40.533333 0 0 1-8.533333-4.821333l-45.312-46.592-129.664-129.749333-46.933334 44.928-130.986666 131.072a41.557333 41.557333 0 0 1-8.533334 4.736 54.357333 54.357333 0 0 1-10.112 3.114666 70.826667 70.826667 0 0 1-14.421333 1.408c-4.608 0-9.685333-0.384-15.274667-1.322666a115.2 115.2 0 0 1-18.133333-4.864 159.914667 159.914667 0 0 1-21.162667-9.258667 223.061333 223.061333 0 0 1-24.021333-14.634667L425.173333 303.786667z m201.386667 33.493333l195.370667 196.181333 58.965333 57.728a223.573333 223.573333 0 0 1-24.064 14.677334 159.146667 159.146667 0 0 1-21.077333 9.258666 115.072 115.072 0 0 1-18.176 4.736c-5.546667 0.981333-10.709333 1.450667-15.36 1.450667-5.504 0-10.282667-0.597333-14.378667-1.450667a54.826667 54.826667 0 0 1-16.426667-6.229333 10.197333 10.197333 0 0 1-2.261333-1.706667l-52.565333-51.968-90.069334-90.069333-14.250666 14.250667L545.706667 418.133333l80.896-80.938666z m-254.549333 175.786667v107.904a90.794667 90.794667 0 0 1-15.189334-1.493334 117.973333 117.973333 0 0 1-18.005333-4.949333 158.208 158.208 0 0 1-20.821333-9.130667 222.592 222.592 0 0 1-23.68-14.506666l77.653333-77.866667z" fill="#0D597F"/></svg>
@@ -271,9 +399,15 @@ function getTemplateIcon(id: string): ReactNode {
if (id.startsWith('archlinux')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M504.149333 7.850667c-44.373333 108.544-70.997333 179.2-120.149333 284.330666 30.037333 32.085333 67.242667 69.290667 127.317333 111.274667-64.512-26.624-108.544-53.248-141.653333-80.896-63.146667 131.413333-161.792 318.464-361.813333 678.229333 157.696-90.794667 279.552-146.773333 393.216-168.277333-4.778667-21.162667-7.509333-43.690667-7.509334-67.584l0.341334-5.12c2.389333-100.693333 54.954667-178.517333 117.077333-173.056s110.592 91.477333 107.861333 192.170667c-0.341333 18.090667-2.389333 36.522667-6.485333 54.272 112.64 21.845333 233.130667 77.824 388.437333 167.594666l-83.968-155.648c-40.96-31.744-83.968-73.386667-171.349333-118.101333 60.074667 15.701333 103.082667 33.792 136.533333 53.930667-265.557333-493.909333-287.061333-559.786667-377.856-773.12z" fill="#1793D1"/></svg> if (id.startsWith('archlinux')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M504.149333 7.850667c-44.373333 108.544-70.997333 179.2-120.149333 284.330666 30.037333 32.085333 67.242667 69.290667 127.317333 111.274667-64.512-26.624-108.544-53.248-141.653333-80.896-63.146667 131.413333-161.792 318.464-361.813333 678.229333 157.696-90.794667 279.552-146.773333 393.216-168.277333-4.778667-21.162667-7.509333-43.690667-7.509334-67.584l0.341334-5.12c2.389333-100.693333 54.954667-178.517333 117.077333-173.056s110.592 91.477333 107.861333 192.170667c-0.341333 18.090667-2.389333 36.522667-6.485333 54.272 112.64 21.845333 233.130667 77.824 388.437333 167.594666l-83.968-155.648c-40.96-31.744-83.968-73.386667-171.349333-118.101333 60.074667 15.701333 103.082667 33.792 136.533333 53.930667-265.557333-493.909333-287.061333-559.786667-377.856-773.12z" fill="#1793D1"/></svg>
if (id.startsWith('fedora')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M512 0C229.344 0 0.224 229.024 0 511.648V907.84a116.384 116.384 0 0 0 116.384 116.128h395.808c282.656-0.128 511.776-229.28 511.776-512 0-282.752-229.248-512-512-512z m196.064 237.952c-16.16 0-22.016-3.104-45.728-3.104a126.848 126.848 0 0 0-126.848 126.624v110.208c0 9.888 8.032 17.92 17.92 17.92h83.328c31.072 0 56.16 24.736 56.16 55.904 0 31.328-25.344 55.968-56.736 55.968h-100.608v127.36a240.32 240.32 0 0 1-240.288 240.288h-1.248a190.944 190.944 0 0 1-53.216-7.52l1.344 0.32c-27.168-7.072-49.376-29.408-49.376-55.296 0-31.328 22.752-54.112 56.736-54.112 16.128 0 22.016 3.072 45.696 3.072a126.848 126.848 0 0 0 126.848-126.624v-110.208a17.92 17.92 0 0 0-17.92-17.888h-83.328a55.808 55.808 0 0 1-56.096-55.904c0-31.328 25.344-55.968 56.736-55.968h100.576v-127.36a240.32 240.32 0 0 1 240.288-240.288c20.128 0 34.432 2.272 53.088 7.136 27.168 7.136 49.408 29.44 49.408 55.296 0 31.36-22.752 54.144-56.736 54.144z" fill="#294172"/></svg> if (id.startsWith('fedora')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M512 0C229.344 0 0.224 229.024 0 511.648V907.84a116.384 116.384 0 0 0 116.384 116.128h395.808c282.656-0.128 511.776-229.28 511.776-512 0-282.752-229.248-512-512-512z m196.064 237.952c-16.16 0-22.016-3.104-45.728-3.104a126.848 126.848 0 0 0-126.848 126.624v110.208c0 9.888 8.032 17.92 17.92 17.92h83.328c31.072 0 56.16 24.736 56.16 55.904 0 31.328-25.344 55.968-56.736 55.968h-100.608v127.36a240.32 240.32 0 0 1-240.288 240.288h-1.248a190.944 190.944 0 0 1-53.216-7.52l1.344 0.32c-27.168-7.072-49.376-29.408-49.376-55.296 0-31.328 22.752-54.112 56.736-54.112 16.128 0 22.016 3.072 45.696 3.072a126.848 126.848 0 0 0 126.848-126.624v-110.208a17.92 17.92 0 0 0-17.92-17.888h-83.328a55.808 55.808 0 0 1-56.096-55.904c0-31.328 25.344-55.968 56.736-55.968h100.576v-127.36a240.32 240.32 0 0 1 240.288-240.288c20.128 0 34.432 2.272 53.088 7.136 27.168 7.136 49.408 29.44 49.408 55.296 0 31.36-22.752 54.144-56.736 54.144z" fill="#294172"/></svg>
if (id.startsWith('rockylinux')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M995.498667 680.362667c18.474667-52.778667 28.501333-109.568 28.501333-168.704C1024 229.077333 794.752 0 512 0S0 229.077333 0 511.658667c0 139.818667 56.106667 266.496 147.114667 358.826666L666.453333 351.530667l128.213334 128.170666 200.832 200.704z m-93.525334 162.816l-235.52-235.349334-368.896 368.597334A510.506667 510.506667 0 0 0 512 1023.274667c156.16 0 296.106667-69.888 389.973333-180.053334h0.042667z" fill="#10B981"/></svg> if (id.startsWith('rockylinux')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M995.498667 680.362667c18.474667-52.778667 28.501333-109.568 28.501333-168.704C1024 229.077333 794.752 0 512 0S0 229.077333 0 511.658667c0 139.818667 56.106667 266.496 147.114667 358.826666L666.453333 351.530667l128.213334 128.170666 200.832 200.704z m-93.525334 162.816l-235.52-235.349334-368.896 368.597334A510.506667 510.506667 0 0 0 512 1023.274667c156.16 0 296.106667-69.888 389.973333-180.053334h0.042667z" fill="#10B981"/></svg>
if (id.startsWith('windows')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M56.888889 227.555556l398.222222-70.542223V512H56.888889V227.555556z m0 625.777777l398.222222 70.542223V568.888889H56.888889v284.444444zM512 147.342222L1024 56.888889v455.111111H512V147.342222z m0 786.204445L1024 1024v-455.111111H512v364.657778z" fill="#16C6FE"/></svg>
return null return null
} }
function apiErrorMessage(err: unknown, fallback: string) {
const error = err as { response?: { data?: { message?: string } }; message?: string }
return error.response?.data?.message || error.message || fallback
}
function formatSize(bytes: number): string { function formatSize(bytes: number): string {
if (bytes <= 0) return '-' if (bytes <= 0) return '-'
if (bytes < 1024) return `${bytes} B` if (bytes < 1024) return `${bytes} B`
+2 -9
View File
@@ -3,13 +3,6 @@ import { Lock, User } from 'lucide-react'
import AppIcon from '../components/AppIcon' import AppIcon from '../components/AppIcon'
import { useAuth } from '../contexts/AuthContext' import { useAuth } from '../contexts/AuthContext'
async function sha256Hash(input: string): Promise<string> {
const msgBuffer = new TextEncoder().encode(input)
const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer)
const hashArray = Array.from(new Uint8Array(hashBuffer))
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('')
}
export default function Login() { export default function Login() {
const { login, accessCodeLogin } = useAuth() const { login, accessCodeLogin } = useAuth()
const [username, setUsername] = useState('') const [username, setUsername] = useState('')
@@ -47,7 +40,7 @@ export default function Login() {
<div className="w-full max-w-md"> <div className="w-full max-w-md">
<div className="bg-white rounded-lg border border-gray-200 shadow-sm p-8"> <div className="bg-white rounded-lg border border-gray-200 shadow-sm p-8">
<div className="flex flex-col items-center mb-8"> <div className="flex flex-col items-center mb-8">
<div className="w-16 h-16 rounded-lg border border-gray-200 bg-gray-50 flex items-center justify-center mb-4"> <div className="w-16 h-16 flex items-center justify-center mb-4">
<AppIcon className="w-10 h-10" /> <AppIcon className="w-10 h-10" />
</div> </div>
<h1 className="text-2xl font-bold text-gray-950">CLICD</h1> <h1 className="text-2xl font-bold text-gray-950">CLICD</h1>
@@ -113,7 +106,7 @@ export default function Login() {
</form> </form>
</div> </div>
<p className="text-center text-xs text-gray-400 mt-6">CLICD v1.0.0</p> <p className="text-center text-xs text-gray-400 mt-6">CLICD v1.1.4</p>
</div> </div>
</div> </div>
) )
-490
View File
@@ -1,490 +0,0 @@
import { useState, useEffect, useCallback, type ReactNode } from 'react'
import { Cpu, MemoryStick, HardDrive, RefreshCw, Save, RotateCcw } from 'lucide-react'
import {
getOversell,
updateOversell,
getOversellStatus,
getHostInfo,
reclaimMemory,
HostInfo,
OversellConfig,
OversellStatus,
} from '../services/api'
import { useDialog } from '../components/Dialog'
import { formatMB } from '../utils/labels'
export default function Oversell() {
const dialog = useDialog()
const [config, setConfig] = useState<OversellConfig | null>(null)
const [status, setStatus] = useState<OversellStatus | null>(null)
const [host, setHost] = useState<HostInfo | null>(null)
const [estimateSpec, setEstimateSpec] = useState({ vcpu: 1, ramMb: 1024, diskGb: 10 })
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [reclaiming, setReclaiming] = useState(false)
const fetchData = useCallback(async () => {
try {
const [cfgRes, stRes, hostRes] = await Promise.all([
getOversell(),
getOversellStatus(),
getHostInfo(),
])
if (cfgRes.data.data) setConfig(cfgRes.data.data)
if (stRes.data.data) setStatus(stRes.data.data)
if (hostRes.data.data) setHost(hostRes.data.data)
} catch (err) {
console.error(err)
} finally {
setLoading(false)
}
}, [])
useEffect(() => { fetchData() }, [fetchData])
const handleSave = async () => {
if (!config) return
if (config.cpu_overcommit < 1 || config.ram_overcommit < 1 || config.disk_overcommit < 1) {
await dialog.alert('参数错误', '超售倍数不能小于 1。')
return
}
if (config.swappiness < 0 || config.swappiness > 100) {
await dialog.alert('参数错误', 'Swap 倾向必须在 0 到 100 之间。')
return
}
setSaving(true)
try {
await updateOversell(config)
await fetchData()
await dialog.alert('已应用', '宿主机控制参数已保存。')
} catch (err) {
console.error(err)
await dialog.alert('保存失败', getErrorMessage(err, '请检查宿主机权限或稍后重试。'))
} finally {
setSaving(false)
}
}
const handleReclaimMemory = async () => {
setReclaiming(true)
try {
const res = await reclaimMemory()
await fetchData()
const result = res.data.data
const errors = result?.errors?.length ? `\n失败: ${result.errors.join('; ')}` : ''
await dialog.alert(
'回收已触发',
`已处理 ${result?.attempted || 0} 个运行中容器,成功 ${result?.reclaimed || 0} 个,不支持 ${result?.unsupported || 0} 个。${errors}`
)
} catch (err) {
console.error(err)
await dialog.alert('回收失败', getErrorMessage(err, '请检查宿主机是否支持 cgroup v2 memory.reclaim。'))
} finally {
setReclaiming(false)
}
}
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-black"></div>
</div>
)
}
if (!config) return null
const estimate = host ? buildCapacityEstimate(host, status, config, estimateSpec) : null
const ksmSupported = status?.ksm_supported !== false
const reclaimSupported = status?.reclaim_supported !== false
return (
<div className="space-y-5">
<div className="flex items-center justify-between gap-4">
<div>
<h1 className="text-xl font-semibold text-black">宿</h1>
<p className="text-sm text-gray-500 mt-1">KSM 宿</p>
</div>
<button
onClick={fetchData}
className="inline-flex items-center gap-2 px-3 py-2 border border-gray-300 text-gray-700 rounded-md hover:bg-gray-50 text-sm"
>
<RefreshCw className="w-4 h-4" />
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<ResourceCard
icon={<Cpu className="w-3.5 h-3.5" />}
label="已分配 vCPU"
value={String(status?.allocated_cpu || 0)}
hint={`超售倍数: ${config.cpu_overcommit}x`}
/>
<ResourceCard
icon={<MemoryStick className="w-3.5 h-3.5" />}
label="已分配内存"
value={formatMB(status?.allocated_ram_mb || 0)}
hint={`超售倍数: ${config.ram_overcommit}x`}
/>
<ResourceCard
icon={<HardDrive className="w-3.5 h-3.5" />}
label="已分配磁盘"
value={`${status?.allocated_disk_gb || 0} GB`}
hint={`超售倍数: ${config.disk_overcommit}x`}
/>
</div>
<div className="bg-white border border-gray-200 rounded-lg p-5">
<h2 className="text-sm font-semibold text-black mb-4"></h2>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<SliderField
label="CPU 超售"
value={config.cpu_overcommit}
min={1}
max={32}
suffix="x"
onChange={(v) => setConfig({ ...config, cpu_overcommit: v })}
hint="只用于容量估算,不改变单台容器限制"
/>
<SliderField
label="内存超售"
value={config.ram_overcommit}
min={1}
max={16}
suffix="x"
onChange={(v) => setConfig({ ...config, ram_overcommit: v })}
hint="只用于容量估算,不改变单台容器限制"
/>
<SliderField
label="磁盘超售"
value={config.disk_overcommit}
min={1}
max={16}
suffix="x"
onChange={(v) => setConfig({ ...config, disk_overcommit: v })}
hint="用于容量预估,实际写入仍受文件系统限制"
/>
</div>
</div>
<div className="bg-white border border-gray-200 rounded-lg p-5">
<div className="flex items-center justify-between gap-4 mb-4">
<h2 className="text-sm font-semibold text-black"></h2>
<span className="text-xs text-gray-500"></span>
</div>
<div className="grid grid-cols-1 lg:grid-cols-[320px_1fr] gap-5">
<div className="grid grid-cols-3 gap-3">
<NumberField
label="vCPU"
value={estimateSpec.vcpu}
min={0.25}
step={0.25}
onChange={(value) => setEstimateSpec({ ...estimateSpec, vcpu: value })}
/>
<NumberField
label="内存 MB"
value={estimateSpec.ramMb}
min={128}
step={128}
onChange={(value) => setEstimateSpec({ ...estimateSpec, ramMb: value })}
/>
<NumberField
label="磁盘 GB"
value={estimateSpec.diskGb}
min={1}
onChange={(value) => setEstimateSpec({ ...estimateSpec, diskGb: value })}
/>
</div>
{estimate && (
<div className="grid grid-cols-1 xl:grid-cols-[220px_1fr] gap-4">
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4">
<div className="text-xs text-gray-500"></div>
<div className="mt-1 text-3xl font-bold text-black">{estimate.remainingCount}</div>
<div className="mt-1 text-xs text-gray-400">
{estimate.totalCount} {estimate.bottleneckLabel}
</div>
</div>
<div className="overflow-hidden rounded-lg border border-gray-200">
<table className="w-full text-sm">
<thead className="bg-gray-50 text-xs text-gray-500">
<tr>
<th className="px-3 py-2 text-left font-medium"></th>
<th className="px-3 py-2 text-right font-medium"></th>
<th className="px-3 py-2 text-right font-medium"></th>
<th className="px-3 py-2 text-right font-medium"></th>
<th className="px-3 py-2 text-right font-medium"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{estimate.rows.map((row) => (
<tr key={row.label}>
<td className="px-3 py-2 text-gray-700">{row.label}</td>
<td className="px-3 py-2 text-right font-mono text-xs text-gray-600">{row.actual}</td>
<td className="px-3 py-2 text-right font-mono text-xs text-gray-600">{row.capacity}</td>
<td className="px-3 py-2 text-right font-mono text-xs text-gray-600">{row.allocated}</td>
<td className="px-3 py-2 text-right font-semibold text-black">{row.remainingCount}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
</div>
<div className="bg-white border border-gray-200 rounded-lg p-5">
<h2 className="text-sm font-semibold text-black mb-4"></h2>
<div className="space-y-4">
<ToggleRow
label="KSM 合并"
desc="合并容器间相同内存页,减少实际内存占用"
value={config.ksm_enabled && ksmSupported}
disabled={!ksmSupported}
onChange={(v) => setConfig({ ...config, ksm_enabled: v })}
extra={ksmSupported ? `已合并 ${status?.ksm_pages || 0}` : '当前内核不支持 KSM'}
/>
<SliderField
label="Swap 倾向"
value={config.swappiness}
min={0}
max={100}
suffix=""
onChange={(v) => setConfig({ ...config, swappiness: v })}
hint="写入 /proc/sys/vm/swappiness,值越低越少使用 swap"
/>
<ActionRow
title="立即回收缓存"
desc={reclaimSupported ? '对运行中容器触发一次 cgroup v2 memory.reclaim' : '当前环境未检测到 memory.reclaim'}
disabled={!reclaimSupported || reclaiming}
busy={reclaiming}
onClick={handleReclaimMemory}
/>
</div>
</div>
<div className="flex justify-end">
<button
onClick={handleSave}
disabled={saving}
className="flex items-center gap-2 px-6 py-2.5 bg-black text-white rounded-md hover:bg-gray-800 transition-colors text-sm font-medium disabled:opacity-50"
>
<Save className="w-4 h-4" />
{saving ? '保存中...' : '应用设置'}
</button>
</div>
</div>
)
}
function ResourceCard({ icon, label, value, hint }: {
icon: ReactNode
label: string
value: string
hint: string
}) {
return (
<div className="bg-white border border-gray-200 rounded-lg p-4">
<div className="flex items-center gap-2 text-xs text-gray-500 mb-1">
{icon}{label}
</div>
<div className="text-2xl font-bold text-black">{value}</div>
<div className="text-xs text-gray-400 mt-0.5">{hint}</div>
</div>
)
}
function SliderField({ label, value, min, max, suffix, onChange, hint }: {
label: string
value: number
min: number
max: number
suffix: string
onChange: (v: number) => void
hint?: string
}) {
return (
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-gray-700">{label}</span>
<span className="text-sm text-gray-500 font-mono">{value}{suffix}</span>
</div>
<input
type="range"
min={min}
max={max}
value={value}
onChange={(e) => onChange(parseInt(e.target.value, 10) || min)}
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-black"
/>
<div className="flex justify-between text-[10px] text-gray-300 mt-0.5">
<span>{min}{suffix}</span><span>{max}{suffix}</span>
</div>
{hint && <div className="text-[10px] text-gray-400 mt-1">{hint}</div>}
</div>
)
}
function NumberField({ label, value, min, step = 1, onChange }: {
label: string
value: number
min: number
step?: number
onChange: (value: number) => void
}) {
return (
<label className="block">
<span className="mb-1.5 block text-xs font-medium text-gray-600">{label}</span>
<input
type="number"
min={min}
step={step}
value={value}
onChange={(e) => {
const parsed = step % 1 === 0 ? parseInt(e.target.value, 10) : parseFloat(e.target.value)
onChange(Math.max(min, Number.isFinite(parsed) ? parsed : min))
}}
className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black focus:border-black focus:outline-none focus:ring-2 focus:ring-black"
/>
</label>
)
}
function ToggleRow({ label, desc, value, disabled = false, onChange, extra }: {
label: string
desc: string
value: boolean
disabled?: boolean
onChange: (v: boolean) => void
extra?: string
}) {
return (
<div className="flex items-center justify-between py-2">
<div>
<div className="text-sm font-medium text-gray-700">{label}</div>
<div className="text-xs text-gray-400">{desc}</div>
{extra && <div className="text-xs text-gray-500 mt-0.5">{extra}</div>}
</div>
<label className={`relative inline-flex items-center ${disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'}`}>
<input
type="checkbox"
checked={value}
disabled={disabled}
onChange={(e) => onChange(e.target.checked)}
className="sr-only peer"
/>
<div className="w-9 h-5 bg-gray-300 peer-checked:bg-black rounded-full after:content-[''] after:absolute after:top-0.5 after:left-0.5 after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:after:translate-x-4"></div>
</label>
</div>
)
}
function ActionRow({ title, desc, disabled, busy, onClick }: {
title: string
desc: string
disabled: boolean
busy: boolean
onClick: () => void
}) {
return (
<div className="flex items-center justify-between py-2">
<div>
<div className="text-sm font-medium text-gray-700">{title}</div>
<div className="text-xs text-gray-400">{desc}</div>
</div>
<button
onClick={onClick}
disabled={disabled}
className="inline-flex items-center gap-2 px-3 py-2 border border-gray-300 text-gray-700 rounded-md hover:bg-gray-50 text-sm disabled:cursor-not-allowed disabled:opacity-50"
>
<RotateCcw className={`w-4 h-4 ${busy ? 'animate-spin' : ''}`} />
{busy ? '回收中...' : '执行'}
</button>
</div>
)
}
type EstimateSpec = {
vcpu: number
ramMb: number
diskGb: number
}
type EstimateRow = {
label: string
actual: string
capacity: string
allocated: string
totalCount: number
remainingCount: number
}
function buildCapacityEstimate(
host: HostInfo,
status: OversellStatus | null,
config: OversellConfig,
spec: EstimateSpec
) {
const cpuCapacity = host.cpu.cores * config.cpu_overcommit
const ramCapacity = host.ram.total_mb * config.ram_overcommit
const diskCapacity = host.disk.total_gb * config.disk_overcommit
const allocatedCPU = status?.allocated_cpu || 0
const allocatedRAM = status?.allocated_ram_mb || 0
const allocatedDisk = status?.allocated_disk_gb || 0
const rows: EstimateRow[] = [
{
label: 'CPU',
actual: `${host.cpu.cores}`,
capacity: `${cpuCapacity} vCPU`,
allocated: `${allocatedCPU} vCPU`,
totalCount: safeFloor(cpuCapacity / spec.vcpu),
remainingCount: safeFloor((cpuCapacity - allocatedCPU) / spec.vcpu),
},
{
label: '内存',
actual: formatMB(Number(host.ram.total_mb)),
capacity: formatMB(ramCapacity),
allocated: formatMB(allocatedRAM),
totalCount: safeFloor(ramCapacity / spec.ramMb),
remainingCount: safeFloor((ramCapacity - allocatedRAM) / spec.ramMb),
},
{
label: '磁盘',
actual: `${host.disk.total_gb} GB`,
capacity: `${diskCapacity} GB`,
allocated: `${allocatedDisk} GB`,
totalCount: safeFloor(diskCapacity / spec.diskGb),
remainingCount: safeFloor((diskCapacity - allocatedDisk) / spec.diskGb),
},
]
const totalCount = Math.min(...rows.map((row) => row.totalCount))
const remainingCount = Math.min(...rows.map((row) => row.remainingCount))
const bottleneck = rows.reduce((current, row) => row.remainingCount < current.remainingCount ? row : current, rows[0])
return {
rows,
totalCount,
remainingCount,
bottleneckLabel: bottleneck.label,
}
}
function safeFloor(value: number): number {
if (!Number.isFinite(value) || value <= 0) return 0
return Math.floor(value)
}
function getErrorMessage(err: unknown, fallback: string): string {
if (typeof err === 'object' && err !== null && 'response' in err) {
const response = (err as { response?: { data?: { message?: string } } }).response
return response?.data?.message || fallback
}
return fallback
}
+23 -5
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'
import { Network, RefreshCw, Route, Search, Server, X } from 'lucide-react' import { RefreshCw, Search, Server, X } from 'lucide-react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { getRoutingInfo, RoutingInfo, NAT4Route, IPv6Route } from '../services/api' import { getRoutingInfo, RoutingInfo, NAT4Route, IPv6Route } from '../services/api'
@@ -96,7 +96,7 @@ export default function Routing() {
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
<CapacityCard <CapacityCard
title="NAT4 端口" title="NAT4 端口"
icon={<Route className="h-5 w-5 text-gray-600" />} icon={<Nat4Icon />}
remaining={routing?.nat4.remaining || '0'} remaining={routing?.nat4.remaining || '0'}
total={routing?.nat4.total || '0'} total={routing?.nat4.total || '0'}
used={routing?.nat4.used || 0} used={routing?.nat4.used || 0}
@@ -104,7 +104,7 @@ export default function Routing() {
/> />
<CapacityCard <CapacityCard
title="IPv6 地址" title="IPv6 地址"
icon={<Network className="h-5 w-5 text-gray-600" />} icon={<IPv6Icon />}
remaining={formatCapacity(routing?.ipv6.remaining || '0')} remaining={formatCapacity(routing?.ipv6.remaining || '0')}
total={formatCapacity(routing?.ipv6.total || '0')} total={formatCapacity(routing?.ipv6.total || '0')}
used={routing?.ipv6.used || 0} used={routing?.ipv6.used || 0}
@@ -137,7 +137,7 @@ export default function Routing() {
</div> </div>
</div> </div>
{nat4Mappings.length === 0 ? ( {nat4Mappings.length === 0 ? (
<EmptyState icon={<Route className="h-7 w-7 text-gray-400" />} text="暂无 NAT4 端口映射" /> <EmptyState icon={<Nat4Icon className="h-7 w-7" />} text="暂无 NAT4 端口映射" />
) : ( ) : (
<> <>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
@@ -214,7 +214,7 @@ export default function Routing() {
</div> </div>
</div> </div>
{ipv6Assignments.length === 0 ? ( {ipv6Assignments.length === 0 ? (
<EmptyState icon={<Network className="h-7 w-7 text-gray-400" />} text="暂无 IPv6 地址分配" /> <EmptyState icon={<IPv6Icon className="h-7 w-7" />} text="暂无 IPv6 地址分配" />
) : ( ) : (
<> <>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
@@ -357,3 +357,21 @@ function formatCapacity(value: string): string {
if (value === 'large') return '充足' if (value === 'large') return '充足'
return value return value
} }
function Nat4Icon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg" fill="currentColor">
<path d="M797.866667 128c64 0 115.2 51.2 119.466666 110.933333v558.933334c0 64-51.2 115.2-110.933333 119.466666H243.2c-59.733333 0-110.933333-51.2-115.2-110.933333V247.466667C128 187.733333 174.933333 136.533333 234.666667 128h563.2z m38.4 473.6H204.8v196.266667c0 21.333333 17.066667 38.4 38.4 38.4h554.666667c21.333333 0 38.4-17.066667 38.4-38.4v-196.266667z m-315.733334 76.8c21.333333 0 38.4 17.066667 38.4 42.666667 0 17.066667-12.8 34.133333-34.133333 38.4H320c-21.333333 0-38.4-17.066667-38.4-42.666667 0-17.066667 12.8-34.133333 34.133333-38.4h204.8z m157.866667 0c21.333333 0 38.4 17.066667 38.4 42.666667 0 17.066667-12.8 34.133333-34.133333 38.4h-46.933334c-21.333333 0-38.4-17.066667-38.4-42.666667 0-17.066667 12.8-34.133333 34.133334-38.4h46.933333z m119.466667-473.6h-554.666667c-21.333333 0-38.4 17.066667-38.4 38.4v277.333333h631.466667V243.2c0-17.066667-17.066667-34.133333-38.4-38.4z" />
<path d="M277.333333 426.666667V243.2h34.133334V426.666667h-34.133334zM426.666667 358.4h-34.133334V426.666667h-34.133333V243.2h72.533333c38.4 0 59.733333 25.6 59.733334 55.466667s-25.6 59.733333-64 59.733333z m-4.266667-81.066667h-34.133333v51.2h34.133333c17.066667 0 25.6-8.533333 25.6-25.6s-8.533333-25.6-25.6-25.6zM571.733333 426.666667h-25.6l-51.2-132.266667h34.133334l25.6 81.066667 25.6-81.066667h34.133333l-42.666667 132.266667zM733.866667 401.066667v25.6h-34.133334v-25.6h-72.533333v-29.866667l64-123.733333h38.4l-64 123.733333h38.4v-34.133333h34.133333v34.133333h17.066667v29.866667h-21.333333z" />
</svg>
)
}
function IPv6Icon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg" fill="currentColor">
<path d="M797.866667 128c64 0 115.2 51.2 119.466666 110.933333v558.933334c0 64-51.2 115.2-110.933333 119.466666H243.2c-59.733333 0-110.933333-51.2-115.2-110.933333V247.466667C128 187.733333 174.933333 136.533333 234.666667 128h563.2z m38.4 473.6H204.8v196.266667c0 21.333333 17.066667 38.4 38.4 38.4h554.666667c21.333333 0 38.4-17.066667 38.4-38.4v-196.266667z m-315.733334 76.8c21.333333 0 38.4 17.066667 38.4 42.666667 0 17.066667-12.8 34.133333-34.133333 38.4H320c-21.333333 0-38.4-17.066667-38.4-42.666667 0-17.066667 12.8-34.133333 34.133333-38.4h204.8z m157.866667 0c21.333333 0 38.4 17.066667 38.4 42.666667 0 17.066667-12.8 34.133333-34.133333 38.4h-46.933334c-21.333333 0-38.4-17.066667-38.4-42.666667 0-17.066667 12.8-34.133333 34.133334-38.4h46.933333z m119.466667-473.6h-554.666667c-21.333333 0-38.4 17.066667-38.4 38.4v277.333333h631.466667V243.2c0-17.066667-17.066667-34.133333-38.4-38.4z" />
<path d="M277.333333 426.666667V243.2h34.133334V426.666667h-34.133334zM426.666667 358.4h-34.133334V426.666667h-34.133333V243.2h72.533333c38.4 0 59.733333 25.6 59.733334 55.466667s-25.6 59.733333-64 59.733333z m-4.266667-81.066667h-34.133333v51.2h34.133333c17.066667 0 25.6-8.533333 25.6-25.6s-8.533333-25.6-25.6-25.6zM571.733333 426.666667h-25.6l-51.2-132.266667h34.133334l25.6 81.066667 25.6-81.066667h34.133333l-42.666667 132.266667zM691.2 426.666667c-34.133333 0-55.466667-21.333333-55.466667-55.466667 0-17.066667 8.533333-34.133333 17.066667-46.933333l38.4-76.8h38.4l-38.4 76.8c4.266667 0 8.533333-4.266667 12.8-4.266667 25.6 0 46.933333 21.333333 46.933333 55.466667-4.266667 29.866667-29.866667 51.2-59.733333 51.2z m0-81.066667c-12.8 0-25.6 8.533333-25.6 25.6 0 17.066667 8.533333 25.6 25.6 25.6s25.6-8.533333 25.6-25.6c-4.266667-17.066667-12.8-25.6-25.6-25.6z" />
</svg>
)
}
+156 -13
View File
@@ -1,6 +1,6 @@
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback } from 'react'
import { RefreshCw } from 'lucide-react' import { FileText, Power, RefreshCw, X } from 'lucide-react'
import { getSecurityAlerts, SecurityAlert } from '../services/api' import { getSecurityAlerts, getSecurityLogs, getSecuritySettings, SecurityAlert, SecurityLog, updateSecuritySettings } from '../services/api'
const typeLabels: Record<string, string> = { const typeLabels: Record<string, string> = {
port_scan: '端口扫描', port_scan: '端口扫描',
@@ -23,12 +23,18 @@ const severityLabels: Record<string, string> = {
export default function Security() { export default function Security() {
const [alerts, setAlerts] = useState<SecurityAlert[]>([]) const [alerts, setAlerts] = useState<SecurityAlert[]>([])
const [autoShutdown, setAutoShutdown] = useState(false)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [savingSettings, setSavingSettings] = useState(false)
const [logAlert, setLogAlert] = useState<SecurityAlert | null>(null)
const [logs, setLogs] = useState<SecurityLog[]>([])
const [logsLoading, setLogsLoading] = useState(false)
const fetchData = useCallback(async () => { const fetchData = useCallback(async () => {
try { try {
const alertRes = await getSecurityAlerts() const [alertRes, settingsRes] = await Promise.all([getSecurityAlerts(), getSecuritySettings()])
if (alertRes.data.data) setAlerts(alertRes.data.data) if (alertRes.data.data) setAlerts(alertRes.data.data)
if (settingsRes.data.data) setAutoShutdown(settingsRes.data.data.auto_shutdown)
} catch (err) { } catch (err) {
console.error(err) console.error(err)
} finally { } finally {
@@ -42,6 +48,36 @@ export default function Security() {
return () => clearInterval(interval) return () => clearInterval(interval)
}, [fetchData]) }, [fetchData])
const handleAutoShutdownChange = async () => {
const next = !autoShutdown
setAutoShutdown(next)
setSavingSettings(true)
try {
const res = await updateSecuritySettings({ auto_shutdown: next })
if (res.data.data) setAutoShutdown(res.data.data.auto_shutdown)
} catch (err) {
console.error(err)
setAutoShutdown(!next)
} finally {
setSavingSettings(false)
}
}
const openLogs = async (alert: SecurityAlert) => {
setLogAlert(alert)
setLogs([])
setLogsLoading(true)
try {
const res = await getSecurityLogs(alert.container_name)
setLogs(filterRelatedLogs(res.data.data || [], alert))
} catch (err) {
console.error(err)
setLogs([])
} finally {
setLogsLoading(false)
}
}
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center py-20"> <div className="flex items-center justify-center py-20">
@@ -52,15 +88,33 @@ export default function Security() {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<h1 className="text-xl font-semibold text-black"></h1> <h1 className="text-xl font-semibold text-black"></h1>
<button <div className="flex flex-wrap items-center gap-2">
onClick={fetchData} <button
className="inline-flex items-center gap-2 px-3 py-2 border border-gray-300 text-gray-700 rounded-md hover:bg-gray-50 text-sm" type="button"
> role="switch"
<RefreshCw className="w-4 h-4" /> aria-checked={autoShutdown}
onClick={handleAutoShutdownChange}
</button> disabled={savingSettings}
title="告警自动关机"
className={`inline-flex h-9 items-center gap-2 rounded-md border px-3 text-sm transition-colors disabled:opacity-60 ${
autoShutdown
? 'border-red-200 bg-red-50 text-red-700 hover:bg-red-100'
: 'border-gray-300 bg-white text-gray-700 hover:bg-gray-50'
}`}
>
<Power className="w-4 h-4" />
<span>{autoShutdown ? '自动关机已开' : '自动关机已关'}</span>
</button>
<button
onClick={fetchData}
className="inline-flex items-center gap-2 px-3 py-2 border border-gray-300 text-gray-700 rounded-md hover:bg-gray-50 text-sm"
>
<RefreshCw className="w-4 h-4" />
</button>
</div>
</div> </div>
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden"> <div className="bg-white border border-gray-200 rounded-lg overflow-hidden">
@@ -93,12 +147,24 @@ export default function Security() {
</td> </td>
<td className="px-4 py-2.5 text-gray-800 whitespace-nowrap">{typeLabels[alert.type] || alert.type}</td> <td className="px-4 py-2.5 text-gray-800 whitespace-nowrap">{typeLabels[alert.type] || alert.type}</td>
<td className="px-4 py-2.5 font-mono text-xs text-gray-700 whitespace-nowrap">{alert.container_name}</td> <td className="px-4 py-2.5 font-mono text-xs text-gray-700 whitespace-nowrap">{alert.container_name}</td>
<td className="px-4 py-2.5 font-mono text-xs text-gray-600 whitespace-nowrap">{alert.source_ip}</td> <td className="px-4 py-2.5 font-mono text-xs text-gray-600 whitespace-nowrap">{alert.source_ip || '-'}</td>
<td className="px-4 py-2.5 font-mono text-xs text-gray-600 whitespace-nowrap"> <td className="px-4 py-2.5 font-mono text-xs text-gray-600 whitespace-nowrap">
{formatTarget(alert)} {formatTarget(alert)}
</td> </td>
<td className="px-4 py-2.5 text-gray-600 whitespace-nowrap">{alert.count}</td> <td className="px-4 py-2.5 text-gray-600 whitespace-nowrap">{alert.count}</td>
<td className="px-4 py-2.5 text-gray-600 min-w-[260px]">{alert.detail}</td> <td className="px-4 py-2.5 text-gray-600 min-w-[300px]">
<div className="flex items-center gap-2">
<span className="min-w-0 flex-1">{alert.detail}</span>
<button
onClick={() => openLogs(alert)}
className="inline-flex shrink-0 items-center gap-1 rounded-md border border-gray-300 px-2 py-1 text-xs text-gray-700 hover:bg-gray-50"
title="查看相关记录"
>
<FileText className="h-3.5 w-3.5" />
</button>
</div>
</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
@@ -106,6 +172,69 @@ export default function Security() {
</div> </div>
)} )}
</div> </div>
{logAlert && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
<div className="w-full max-w-4xl overflow-hidden rounded-lg border border-gray-200 bg-white shadow-xl">
<div className="flex items-start justify-between gap-3 border-b border-gray-200 px-4 py-3">
<div>
<h3 className="text-sm font-semibold text-black"></h3>
<p className="mt-1 text-xs text-gray-500">
{logAlert.container_name} · {typeLabels[logAlert.type] || logAlert.type} · {formatTarget(logAlert)}
</p>
</div>
<button
onClick={() => setLogAlert(null)}
className="rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-black"
title="关闭"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="max-h-[70vh] overflow-auto">
{logAlert.log_line && (
<div className="border-b border-gray-100 bg-gray-50 px-4 py-3">
<div className="mb-1 text-xs font-medium text-gray-600"></div>
<pre className="whitespace-pre-wrap break-all rounded border border-gray-200 bg-white p-3 text-xs text-gray-700">{logAlert.log_line}</pre>
</div>
)}
{logsLoading ? (
<div className="p-8 text-center text-sm text-gray-500">...</div>
) : logs.length === 0 ? (
<div className="p-8 text-center text-sm text-gray-500">
conntrack
</div>
) : (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-100 bg-gray-50 text-left text-xs font-medium text-gray-500">
<th className="px-4 py-2.5"></th>
<th className="px-4 py-2.5"></th>
<th className="px-4 py-2.5"></th>
<th className="px-4 py-2.5"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{logs.map((log, index) => (
<tr key={`${log.src_ip}-${log.src_port}-${log.dst_ip}-${log.dst_port}-${index}`}>
<td className="px-4 py-2.5 font-mono text-xs text-gray-700">{log.protocol || '-'}</td>
<td className="px-4 py-2.5 font-mono text-xs text-gray-700">{log.state || '-'}</td>
<td className="px-4 py-2.5 font-mono text-xs text-gray-600">
{formatEndpoint(log.src_ip, log.src_port)}
</td>
<td className="px-4 py-2.5 font-mono text-xs text-gray-600">
{formatEndpoint(log.dst_ip, log.dst_port)}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
</div>
)}
</div> </div>
) )
} }
@@ -129,3 +258,17 @@ function formatTarget(alert: SecurityAlert): string {
if (!alert.target_ip) return '-' if (!alert.target_ip) return '-'
return alert.target_port > 0 ? `${alert.target_ip}:${alert.target_port}` : alert.target_ip return alert.target_port > 0 ? `${alert.target_ip}:${alert.target_port}` : alert.target_ip
} }
function filterRelatedLogs(logs: SecurityLog[], alert: SecurityAlert): SecurityLog[] {
return logs.filter((log) => {
if (alert.source_ip && log.src_ip !== alert.source_ip) return false
if (alert.target_ip && alert.target_ip !== '*' && log.dst_ip !== alert.target_ip) return false
if (alert.target_port > 0 && log.dst_port !== alert.target_port) return false
return true
})
}
function formatEndpoint(ip: string, port: number): string {
if (!ip) return '-'
return port > 0 ? `${ip}:${port}` : ip
}
+57 -45
View File
@@ -48,6 +48,7 @@ export interface Container {
id: number id: number
uuid: string uuid: string
name: string name: string
virtualization?: string
template: string template: string
vcpu: number vcpu: number
ram_mb: number ram_mb: number
@@ -80,20 +81,26 @@ export interface Container {
snapshot_schedule_last_run: string snapshot_schedule_last_run: string
snapshot_schedule_next_run: string snapshot_schedule_next_run: string
snapshot_schedule_created_by: string snapshot_schedule_created_by: string
policy_blocked?: boolean
policy_blocked_reason?: string
policy_blocked_at?: string
} }
export interface Template { export interface Template {
id: string id: string
name: string name: string
type?: string
distro: string distro: string
release: string release: string
arch: string arch: string
variant?: string variant?: string
desktop?: string
description: string description: string
} }
export interface CreateContainerRequest { export interface CreateContainerRequest {
name: string name: string
virtualization: string
template_id: string template_id: string
vcpu: number vcpu: number
cpu_percent: number cpu_percent: number
@@ -156,6 +163,7 @@ export interface HostInfo {
export interface ContainerUsage { export interface ContainerUsage {
memory_usage_bytes: number memory_usage_bytes: number
memory_total_bytes?: number
cpu_usage_usec: number cpu_usage_usec: number
cpu_usage_pct: number cpu_usage_pct: number
disk_usage_bytes: number disk_usage_bytes: number
@@ -167,6 +175,10 @@ export interface ContainerUsage {
disk_write_bytes: number disk_write_bytes: number
disk_read_bps: number disk_read_bps: number
disk_write_bps: number disk_write_bps: number
load1: number
load5: number
load15: number
guest_metrics?: boolean
} }
export interface APIResponse<T = unknown> { export interface APIResponse<T = unknown> {
@@ -233,8 +245,8 @@ export const restartContainer = (id: ContainerIdentifier) =>
export const reinstallContainer = (id: ContainerIdentifier, templateId: string) => export const reinstallContainer = (id: ContainerIdentifier, templateId: string) =>
api.post<APIResponse>(`/containers/${id}/reinstall`, { template_id: templateId }) api.post<APIResponse>(`/containers/${id}/reinstall`, { template_id: templateId })
export const resetSSHPassword = (id: ContainerIdentifier) => export const resetSSHPassword = (id: ContainerIdentifier, password?: string) =>
api.post<APIResponse<{ password: string }>>(`/containers/${id}/reset-password`) api.post<APIResponse<{ password: string }>>(`/containers/${id}/reset-password`, password ? { password } : {})
export const getContainerUsage = (id: ContainerIdentifier) => export const getContainerUsage = (id: ContainerIdentifier) =>
api.get<APIResponse<ContainerUsage>>(`/containers/${id}/usage`) api.get<APIResponse<ContainerUsage>>(`/containers/${id}/usage`)
@@ -338,6 +350,7 @@ export const getTemplates = () =>
export interface ImageInfo { export interface ImageInfo {
id: string id: string
name: string name: string
type: string
distro: string distro: string
release: string release: string
arch: string arch: string
@@ -345,14 +358,24 @@ export interface ImageInfo {
downloaded: boolean downloaded: boolean
enabled: boolean enabled: boolean
downloading: boolean downloading: boolean
progress: number
downloaded_bytes: number
total_bytes: number
stage?: string
error?: string
size_bytes: number size_bytes: number
manual_path?: string
desktop?: string
} }
export const getImages = () => export const getImages = () =>
api.get<APIResponse<ImageInfo[]>>('/images') api.get<APIResponse<ImageInfo[]>>('/images')
export const downloadImage = (templateId: string) => export const downloadImage = (templateId: string) =>
api.post<APIResponse>('/images/download', { template_id: templateId }, { timeout: 600000 }) // 10min timeout api.post<APIResponse>('/images/download', { template_id: templateId })
export const cancelImageDownload = (templateId: string) =>
api.post<APIResponse>('/images/cancel', { template_id: templateId })
export const deleteImage = (templateId: string) => export const deleteImage = (templateId: string) =>
api.delete<APIResponse>('/images/delete', { data: { template_id: templateId } }) api.delete<APIResponse>('/images/delete', { data: { template_id: templateId } })
@@ -360,8 +383,8 @@ export const deleteImage = (templateId: string) =>
export const toggleImage = (templateId: string, enabled: boolean) => export const toggleImage = (templateId: string, enabled: boolean) =>
api.put<APIResponse>('/images/toggle', { template_id: templateId, enabled }) api.put<APIResponse>('/images/toggle', { template_id: templateId, enabled })
export const getEnabledImages = () => export const getEnabledImages = (virtualization = 'lxc') =>
api.get<APIResponse<Template[]>>('/images/enabled') api.get<APIResponse<Template[]>>('/images/enabled', { params: { type: virtualization } })
// Dashboard // Dashboard
export const getDashboard = () => export const getDashboard = () =>
@@ -370,45 +393,6 @@ export const getDashboard = () =>
export const getHostInfo = () => export const getHostInfo = () =>
api.get<APIResponse<HostInfo>>('/host-info') api.get<APIResponse<HostInfo>>('/host-info')
// Oversell
export interface OversellConfig {
cpu_overcommit: number
ram_overcommit: number
disk_overcommit: number
ksm_enabled: boolean
swappiness: number
}
export interface OversellStatus {
ksm_active: boolean
ksm_pages: number
ksm_supported: boolean
swappiness: number
reclaim_supported: boolean
allocated_cpu: number
allocated_ram_mb: number
allocated_disk_gb: number
}
export interface ReclaimResult {
attempted: number
reclaimed: number
unsupported: number
errors: string[]
}
export const getOversell = () =>
api.get<APIResponse<OversellConfig>>('/oversell')
export const updateOversell = (data: OversellConfig) =>
api.post<APIResponse<OversellConfig>>('/oversell', data)
export const getOversellStatus = () =>
api.get<APIResponse<OversellStatus>>('/oversell/status')
export const reclaimMemory = () =>
api.post<APIResponse<ReclaimResult>>('/oversell/reclaim')
// Snapshots // Snapshots
export interface Snapshot { export interface Snapshot {
id: string id: string
@@ -472,6 +456,12 @@ export const getWebSSHUrl = (containerName: string) => {
return `${protocol}//${window.location.host}/api/ssh?${params.toString()}` return `${protocol}//${window.location.host}/api/ssh?${params.toString()}`
} }
export const getWebVNCUrl = (containerName: string) => {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const params = new URLSearchParams({ container: containerName })
return `${protocol}//${window.location.host}/api/vnc?${params.toString()}`
}
// Task Queue // Task Queue
export interface Task { export interface Task {
id: string id: string
@@ -546,6 +536,19 @@ export interface SecuritySummary {
low: number low: number
} }
export interface SecuritySettings {
auto_shutdown: boolean
}
export interface SecurityLog {
src_ip: string
dst_ip: string
src_port: number
dst_port: number
protocol: string
state: string
}
export const getSecurityAlerts = () => export const getSecurityAlerts = () =>
api.get<APIResponse<SecurityAlert[]>>('/security/alerts') api.get<APIResponse<SecurityAlert[]>>('/security/alerts')
@@ -553,14 +556,23 @@ export const checkContainerSecurity = (containerName: string) =>
api.post<APIResponse>('/security/check', { container_name: containerName }) api.post<APIResponse>('/security/check', { container_name: containerName })
export const getSecurityLogs = (containerName: string) => export const getSecurityLogs = (containerName: string) =>
api.get<APIResponse>('/security/logs', { params: { container: containerName } }) api.get<APIResponse<SecurityLog[]>>('/security/logs', { params: { container: containerName } })
export const getSecuritySummary = () => export const getSecuritySummary = () =>
api.get<APIResponse<SecuritySummary>>('/security/summary') api.get<APIResponse<SecuritySummary>>('/security/summary')
export const getSecuritySettings = () =>
api.get<APIResponse<SecuritySettings>>('/security/settings')
export const updateSecuritySettings = (data: SecuritySettings) =>
api.put<APIResponse<SecuritySettings>>('/security/settings', data)
export const createWebSSHTicket = (containerName: string) => export const createWebSSHTicket = (containerName: string) =>
api.post<APIResponse<{ ticket: string }>>('/ssh-ticket', { container_name: containerName }) api.post<APIResponse<{ ticket: string }>>('/ssh-ticket', { container_name: containerName })
export const createVNCTicket = (containerName: string) =>
api.post<APIResponse<{ ticket: string }>>('/vnc-ticket', { container_name: containerName })
// Version // Version
export const getVersion = () => export const getVersion = () =>
api.get<APIResponse<{ version: string }>>('/version') api.get<APIResponse<{ version: string }>>('/version')
+1
View File
@@ -14,5 +14,6 @@ export default defineConfig({
}, },
build: { build: {
outDir: 'dist', outDir: 'dist',
target: 'es2022',
} }
}) })
+507 -85
View File
@@ -5,17 +5,36 @@ REPO="${CLICD_REPO:-MengMengCode/CLICD}"
CLICD_INSTALL_VERSION="${CLICD_VERSION:-latest}" CLICD_INSTALL_VERSION="${CLICD_VERSION:-latest}"
ASSET="clicd-linux-amd64.tar.gz" ASSET="clicd-linux-amd64.tar.gz"
ACTION="${1:-install}" ACTION="${1:-install}"
ACTION_CONFIRM="${2:-}"
ISSUE_URL="https://github.com/${REPO}/issues"
LOG_FILE="${CLICD_LOG_FILE:-/var/log/clicd-install.log}"
echo "=====================================" echo "====================================="
echo " CLICD Installer" echo " CLICD 中文安装/卸载脚本"
echo "=====================================" echo "====================================="
write_log_file() {
if [ "$(id -u 2>/dev/null || echo 1)" = "0" ]; then
printf '%s %s\n' "$(date '+%Y-%m-%d %H:%M:%S' 2>/dev/null || true)" "$*" >> "$LOG_FILE" 2>/dev/null || true
fi
}
log() { log() {
echo "[clicd] $*" echo "[clicd] $*"
write_log_file "[clicd] $*"
}
warn() {
echo "[clicd][警告] $*" >&2
write_log_file "[警告] $*"
} }
die() { die() {
echo "ERROR: $*" >&2 echo "[clicd][错误] $*" >&2
write_log_file "[错误] $*"
echo "" >&2
echo "安装/卸载未完成。请查看日志:$LOG_FILE" >&2
echo "如果你确认这是程序问题,请提交 issue:$ISSUE_URL" >&2
exit 1 exit 1
} }
@@ -31,13 +50,70 @@ is_openrc() {
has_cmd rc-service && has_cmd rc-update has_cmd rc-service && has_cmd rc-update
} }
run_step() {
step_name="$1"
shift
log "开始:$step_name"
if "$@" >> "$LOG_FILE" 2>&1; then
log "完成:$step_name"
return 0
fi
rc="$?"
echo "" >&2
echo "[clicd][错误] 步骤失败:$step_name,退出码:$rc" >&2
echo "[clicd][错误] 最近 80 行日志:$LOG_FILE" >&2
tail -n 80 "$LOG_FILE" >&2 2>/dev/null || true
echo "" >&2
echo "请将上述日志和系统信息提交到:$ISSUE_URL" >&2
exit "$rc"
}
check_os_compatibility() {
log "系统检测:ID=${OS_ID} ID_LIKE=${OS_LIKE} ARCH=$(uname -m 2>/dev/null || echo unknown)"
case "$(uname -m 2>/dev/null || echo unknown)" in
x86_64|amd64)
;;
*)
die "当前安装包仅支持 x86_64/amd64,当前架构:$(uname -m 2>/dev/null || echo unknown)"
;;
esac
if ! is_systemd && ! is_openrc; then
die "未检测到 systemd 或 OpenRC,无法安装服务。"
fi
case "$OS_ID" in
ubuntu|debian|alpine|centos|rhel|rocky|almalinux|fedora)
;;
*)
if ! has_cmd apt-get && ! has_cmd apk && ! has_cmd dnf && ! has_cmd yum; then
die "暂不支持当前 Linux 发行版:${OS_ID} ${OS_LIKE}。请提交 issue 并附上 /etc/os-release。"
fi
warn "发行版 ${OS_ID} 不在主要支持列表,将按检测到的软件包管理器尝试安装。"
;;
esac
}
check_storage_compatibility() {
root_fs="$(findmnt -no FSTYPE / 2>/dev/null || echo unknown)"
avail_kb="$(df -Pk / 2>/dev/null | awk 'NR==2 {print $4}' || echo 0)"
log "存储检测:根文件系统=${root_fs} 可用空间=${avail_kb}KB"
if [ "${avail_kb:-0}" -lt 5242880 ]; then
warn "根分区可用空间低于 5GB,下载镜像或创建 KVM/LXC 时可能失败。"
fi
}
if [ "$(id -u)" -ne 0 ]; then if [ "$(id -u)" -ne 0 ]; then
echo "Please run as root: sudo ./install.sh" echo "请使用 root 权限运行:sudo ./install.sh"
echo "Or: 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"
echo "Uninstall: curl -fsSL https://raw.githubusercontent.com/${REPO}/main/install.sh | sudo sh -s -- uninstall" echo "卸载:curl -fsSL https://raw.githubusercontent.com/${REPO}/main/install.sh | sudo sh -s -- uninstall"
echo "问题反馈:$ISSUE_URL"
exit 1 exit 1
fi fi
: > "$LOG_FILE" 2>/dev/null || true
log "日志文件:$LOG_FILE"
log "仓库地址:https://github.com/${REPO}"
log "问题反馈:$ISSUE_URL"
OS_ID="unknown" OS_ID="unknown"
OS_LIKE="" OS_LIKE=""
if [ -r /etc/os-release ]; then if [ -r /etc/os-release ]; then
@@ -48,17 +124,21 @@ fi
usage() { usage() {
cat << EOF cat << EOF
Usage: 用法:
./install.sh ./install.sh 安装或升级 CLICD
./install.sh uninstall ./install.sh uninstall 卸载 CLICD(会删除容器、虚拟机、镜像缓存和配置数据)
Environment: 环境变量:
CLICD_REPO=owner/repo CLICD_REPO=owner/repo 默认:${REPO}
CLICD_VERSION=latest|v1.0.0 CLICD_VERSION=latest|v1.0.0 默认:latest
CLICD_LOG_FILE=/path/file.log 默认:${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
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
日志:${LOG_FILE}
问题反馈:${ISSUE_URL}
EOF EOF
} }
@@ -68,7 +148,7 @@ remove_path() {
return return
fi fi
rm -rf "$path" rm -rf "$path"
log "Removed $path" log "已删除 $path"
} }
unmount_path_tree() { unmount_path_tree() {
@@ -125,21 +205,198 @@ remove_lxc_container_dir() {
detach_container_loop_devices "$container_dir" detach_container_loop_devices "$container_dir"
if rm -rf "$container_dir" >/dev/null 2>&1; then if rm -rf "$container_dir" >/dev/null 2>&1; then
log "Removed $container_dir" log "已删除 $container_dir"
return return
fi fi
log "Retrying removal after terminating processes using $container_dir..." log "检测到 $container_dir 被占用,终止占用进程后重试删除..."
kill_path_users "$container_dir/rootfs" kill_path_users "$container_dir/rootfs"
kill_path_users "$container_dir" kill_path_users "$container_dir"
unmount_path_tree "$container_dir" unmount_path_tree "$container_dir"
detach_container_loop_devices "$container_dir" detach_container_loop_devices "$container_dir"
rm -rf "$container_dir" rm -rf "$container_dir"
log "Removed $container_dir" log "已删除 $container_dir"
}
remove_kvm_domain() {
domain="$1"
case "$domain" in
vm-[0-9]*)
;;
*)
return
;;
esac
suffix="${domain#vm-}"
case "$suffix" in
""|*[!0-9]*)
return
;;
esac
if [ ! -d "/var/lib/clicd/kvm/instances/$domain" ] &&
! virsh dumpxml "$domain" 2>/dev/null | grep -q '/var/lib/clicd/kvm/'; then
return
fi
log "正在删除 KVM 虚拟机域 $domain..."
virsh destroy "$domain" >/dev/null 2>&1 || true
virsh undefine "$domain" --remove-all-storage --nvram >/dev/null 2>&1 ||
virsh undefine "$domain" --nvram >/dev/null 2>&1 ||
virsh undefine "$domain" >/dev/null 2>&1 ||
true
}
destroy_clicd_kvm_domains() {
if ! has_cmd virsh; then
return
fi
log "正在销毁 CLICD 创建的 KVM 虚拟机..."
virsh list --all --name 2>/dev/null | while IFS= read -r domain; do
[ -n "$domain" ] || continue
remove_kvm_domain "$domain"
done
}
delete_iptables_lines() {
table="$1"
chain="$2"
pattern="$3"
if ! has_cmd iptables; then
return
fi
while :; do
line="$(iptables -t "$table" -L "$chain" -n --line-numbers 2>/dev/null | awk -v pat="$pattern" '$0 ~ pat {print $1; exit}')"
[ -n "$line" ] || break
iptables -t "$table" -D "$chain" "$line" >/dev/null 2>&1 || break
done
}
delete_iptables_rule() {
table="$1"
shift
if ! has_cmd iptables; then
return
fi
while iptables -t "$table" -D "$@" >/dev/null 2>&1; do
:
done
}
delete_filter_rule() {
if ! has_cmd iptables; then
return
fi
while iptables -D "$@" >/dev/null 2>&1; do
:
done
}
delete_ip6tables_bridge_rules() {
if ! has_cmd ip6tables; then
return
fi
for bridge in lxcbr0 virbr0; do
while :; do
rule="$(ip6tables -S FORWARD 2>/dev/null | grep -- "$bridge" | sed 's/^-A /-D /' | head -n 1)"
[ -n "$rule" ] || break
# shellcheck disable=SC2086
ip6tables $rule >/dev/null 2>&1 || break
done
done
}
cleanup_clicd_networking() {
log "正在清理 CLICD 防火墙和网桥规则..."
delete_iptables_lines nat PREROUTING 'clicd-'
delete_iptables_rule nat POSTROUTING -s 10.0.3.0/24 -o eth+ -j MASQUERADE
delete_iptables_rule nat POSTROUTING -s 192.168.122.0/24 -o eth+ -j MASQUERADE
for bridge in lxcbr0 virbr0; do
delete_filter_rule FORWARD -i "$bridge" -j ACCEPT
delete_filter_rule FORWARD -o "$bridge" -j ACCEPT
delete_filter_rule FORWARD -i "$bridge" -o "$bridge" -j ACCEPT
done
delete_ip6tables_bridge_rules
}
remove_clicd_host_hooks() {
if has_cmd systemctl; then
systemctl stop clicd-kvm-ipv6.service >/dev/null 2>&1 || true
systemctl disable clicd-kvm-ipv6.service >/dev/null 2>&1 || true
fi
if has_cmd rc-service; then
rc-service clicd-kvm-ipv6 stop >/dev/null 2>&1 || true
fi
if has_cmd rc-update; then
rc-update del clicd-kvm-ipv6 default >/dev/null 2>&1 || true
fi
remove_path /usr/local/sbin/clicd-kvm-ipv6-init
remove_path /etc/systemd/system/clicd-kvm-ipv6.service
remove_path /etc/local.d/clicd-kvm-ipv6.start
remove_path /etc/network/if-up.d/clicd-kvm-ipv6
}
remove_clicd_quota_records() {
for file in /etc/projects /etc/projid; do
[ -f "$file" ] || continue
tmp="${file}.clicd-clean.$$"
grep -v 'clicd-' "$file" > "$tmp" || true
cat "$tmp" > "$file"
rm -f "$tmp"
log "已清理 $file 中的 CLICD 配额记录"
done
}
remove_clicd_tmp_files() {
current_dir="$(pwd -P 2>/dev/null || pwd)"
for path in /tmp/clicd-* /tmp/clicd.*; do
[ -e "$path" ] || [ -L "$path" ] || continue
abs_path="$(cd "$(dirname "$path")" 2>/dev/null && pwd -P)/$(basename "$path")"
if [ "$abs_path" = "$current_dir" ]; then
log "跳过当前安装目录 $path,避免中断后续安装步骤。"
continue
fi
rm -rf "$path"
log "已删除 $path"
done
}
remove_clicd_swapfile() {
if [ ! -e /swapfile ]; then
return
fi
swapoff /swapfile >/dev/null 2>&1 || true
remove_path /swapfile
}
confirm_uninstall() {
if [ "${CLICD_UNINSTALL_CONFIRM:-}" = "1" ] || [ "${CLICD_UNINSTALL_CONFIRM:-}" = "yes" ] || [ "$ACTION_CONFIRM" = "--yes" ] || [ "$ACTION_CONFIRM" = "-y" ]; then
return
fi
echo ""
echo "[clicd][警告] 卸载会停止并删除 CLICD 服务、配置数据库、CLICD 创建的 LXC/KVM 实例和缓存数据。" >&2
echo "[clicd][警告] 为避免误删生产数据,脚本只会删除名称形如 ct-数字 的 LXC 容器和 vm-数字 的 KVM 域。" >&2
echo "如需确认卸载,请输入:YES" >&2
if [ -t 0 ]; then
read answer
else
answer=""
fi
if [ "$answer" != "YES" ]; then
die "已取消卸载。如需非交互卸载,请设置 CLICD_UNINSTALL_CONFIRM=1。"
fi
} }
uninstall_clicd() { uninstall_clicd() {
log "Uninstalling CLICD..." confirm_uninstall
log "正在卸载 CLICD..."
if has_cmd systemctl; then if has_cmd systemctl; then
systemctl stop clicd >/dev/null 2>&1 || true systemctl stop clicd >/dev/null 2>&1 || true
@@ -153,11 +410,15 @@ uninstall_clicd() {
rc-update del clicd default >/dev/null 2>&1 || true rc-update del clicd default >/dev/null 2>&1 || true
fi fi
log "Destroying LXC containers under /var/lib/lxc..." log "正在删除 CLICD 创建的 LXC 容器(/var/lib/lxc/ct-数字)..."
for container_dir in /var/lib/lxc/*; do for container_dir in /var/lib/lxc/ct-[0-9]*; do
[ -d "$container_dir" ] || continue [ -d "$container_dir" ] || continue
remove_lxc_container_dir "$container_dir" remove_lxc_container_dir "$container_dir"
done done
destroy_clicd_kvm_domains
cleanup_clicd_networking
remove_clicd_host_hooks
remove_clicd_quota_records
remove_path /etc/systemd/system/clicd.service remove_path /etc/systemd/system/clicd.service
remove_path /etc/init.d/clicd remove_path /etc/init.d/clicd
@@ -166,9 +427,14 @@ uninstall_clicd() {
remove_path /var/log/clicd.log remove_path /var/log/clicd.log
remove_path /var/log/clicd.err remove_path /var/log/clicd.err
remove_path /root/.clicd remove_path /root/.clicd
unmount_path_tree /var/lib/lxc # /var/lib/lxc 可能包含非 CLICD 容器,生产环境不整体删除。
remove_path /var/lib/lxc unmount_path_tree /var/lib/clicd
remove_path /var/cache/lxc remove_path /var/lib/clicd
# /var/cache/lxc 是 LXC 全局镜像缓存,可能被其他工具复用,生产环境不整体删除。
remove_path /var/cache/clicd
warn "保留 /root/clicd-backups,避免误删部署/回滚备份。确认不需要后可手动删除。"
remove_clicd_tmp_files
remove_clicd_swapfile
if has_cmd systemctl; then if has_cmd systemctl; then
systemctl daemon-reload >/dev/null 2>&1 || true systemctl daemon-reload >/dev/null 2>&1 || true
@@ -180,9 +446,13 @@ uninstall_clicd() {
echo "" echo ""
echo "=====================================" echo "====================================="
echo " CLICD Uninstalled" echo " CLICD 卸载完成"
echo "=====================================" echo "====================================="
echo " Removed service, binary, config, containers, and LXC image cache." echo " 已删除服务、二进制、SQLite/配置数据、CLICD LXC/KVM 实例、"
echo " CLICD 镜像缓存、防火墙规则、主机钩子、配额记录和临时文件。"
echo " 已保留 /root/clicd-backups 和 LXC 全局缓存,避免误删生产备份/共享镜像。"
echo " 日志:$LOG_FILE"
echo " 问题反馈:$ISSUE_URL"
echo "=====================================" echo "====================================="
} }
@@ -198,12 +468,12 @@ case "$ACTION" in
exit 0 exit 0
;; ;;
*) *)
die "Unknown action: $ACTION" die "未知操作:$ACTION"
;; ;;
esac esac
install_apk() { install_apk() {
log "Installing dependencies with apk..." log "正在使用 apk 安装依赖..."
apk update apk update
apk add --no-cache \ apk add --no-cache \
ca-certificates \ ca-certificates \
@@ -220,15 +490,22 @@ install_apk() {
bridge-utils \ bridge-utils \
iproute2 \ iproute2 \
iptables \ iptables \
dnsmasq dnsmasq \
dbus \
qemu-system-x86_64 \
qemu-img \
libvirt \
libvirt-daemon \
libvirt-client \
libvirt-qemu
for pkg in lxcfs shadow conntrack-tools quota-tools e2fsprogs xfsprogs; do for pkg in lxcfs shadow conntrack-tools quota-tools e2fsprogs xfsprogs cloud-utils genisoimage xorriso; do
apk add --no-cache "$pkg" >/dev/null 2>&1 || log "Optional package not installed: $pkg" apk add --no-cache "$pkg" >/dev/null 2>&1 || warn "可选依赖未安装:$pkg"
done done
} }
install_apt() { install_apt() {
log "Installing dependencies with apt..." log "正在使用 apt 安装依赖..."
export DEBIAN_FRONTEND=noninteractive export DEBIAN_FRONTEND=noninteractive
apt-get update apt-get update
apt-get install -y \ apt-get install -y \
@@ -249,7 +526,16 @@ install_apt() {
quota \ quota \
e2fsprogs \ e2fsprogs \
xfsprogs \ xfsprogs \
dnsmasq-base dnsmasq-base \
qemu-kvm \
qemu-utils \
libvirt-daemon-system \
libvirt-clients \
cloud-image-utils \
genisoimage \
xorriso \
virtinst \
ovmf
} }
enable_el_repos() { enable_el_repos() {
@@ -266,7 +552,7 @@ enable_el_repos() {
} }
install_dnf() { install_dnf() {
log "Installing dependencies with dnf..." log "正在使用 dnf 安装依赖..."
enable_el_repos enable_el_repos
dnf install -y \ dnf install -y \
ca-certificates \ ca-certificates \
@@ -285,13 +571,23 @@ install_dnf() {
quota \ quota \
e2fsprogs \ e2fsprogs \
xfsprogs \ xfsprogs \
dnsmasq dnsmasq \
qemu-kvm \
qemu-img \
libvirt \
libvirt-daemon-kvm \
libvirt-client \
virt-install \
cloud-utils \
genisoimage
dnf install -y lxcfs >/dev/null 2>&1 || log "Optional package not installed: lxcfs" for pkg in lxcfs xorriso edk2-ovmf; do
dnf install -y "$pkg" >/dev/null 2>&1 || warn "可选依赖未安装:$pkg"
done
} }
install_yum() { install_yum() {
log "Installing dependencies with yum..." log "正在使用 yum 安装依赖..."
enable_el_repos enable_el_repos
yum install -y \ yum install -y \
ca-certificates \ ca-certificates \
@@ -310,9 +606,19 @@ install_yum() {
quota \ quota \
e2fsprogs \ e2fsprogs \
xfsprogs \ xfsprogs \
dnsmasq dnsmasq \
qemu-kvm \
qemu-img \
libvirt \
libvirt-daemon-kvm \
libvirt-client \
virt-install \
cloud-utils \
genisoimage
yum install -y lxcfs >/dev/null 2>&1 || log "Optional package not installed: lxcfs" for pkg in lxcfs xorriso edk2-ovmf; do
yum install -y "$pkg" >/dev/null 2>&1 || warn "可选依赖未安装:$pkg"
done
} }
install_dependencies() { install_dependencies() {
@@ -329,7 +635,7 @@ install_dependencies() {
elif has_cmd yum; then elif has_cmd yum; then
install_yum install_yum
else else
die "dnf/yum not found on $OS_ID" die "当前系统 $OS_ID 未找到 dnf/yum,无法安装依赖。"
fi fi
;; ;;
*) *)
@@ -342,18 +648,27 @@ install_dependencies() {
elif has_cmd yum; then elif has_cmd yum; then
install_yum install_yum
else else
die "Unsupported Linux distribution: ${OS_ID} ${OS_LIKE}" die "暂不支持当前 Linux 发行版:${OS_ID} ${OS_LIKE}。请提交 issue 并附上 /etc/os-release。"
fi fi
;; ;;
esac esac
has_cmd lxc-create || die "lxc-create is still missing after dependency installation." has_cmd lxc-create || die "依赖安装后仍未找到 lxc-create,请检查 LXC 软件源/安装日志。"
has_cmd iptables || die "iptables is still missing after dependency installation." has_cmd iptables || die "依赖安装后仍未找到 iptables,请检查系统网络工具包。"
has_cmd ip || die "iproute2/ip command is still missing after dependency installation." has_cmd ip || die "依赖安装后仍未找到 ip 命令,请检查 iproute2 安装。"
has_cmd virsh || die "依赖安装后仍未找到 virsh,请检查 libvirt-client/libvirt-clients 安装。"
has_cmd qemu-img || die "依赖安装后仍未找到 qemu-img,请检查 qemu-utils/qemu-img 安装。"
has_cmd cloud-localds || die "依赖安装后仍未找到 cloud-localds,请检查 cloud-image-utils/cloud-utils 安装。"
if ! has_cmd genisoimage && ! has_cmd mkisofs && ! has_cmd xorriso; then
die "Windows KVM 初始化需要 genisoimage、mkisofs 或 xorriso 中任意一个。"
fi
if [ ! -e /dev/kvm ]; then
warn "未检测到 /dev/kvm。LXC 可用,但 KVM 虚拟机需要硬件虚拟化或嵌套虚拟化。"
fi
} }
configure_kernel_networking() { configure_kernel_networking() {
log "Enabling kernel forwarding settings..." log "正在启用内核转发配置..."
cat > /etc/sysctl.d/99-clicd.conf << 'EOF' cat > /etc/sysctl.d/99-clicd.conf << 'EOF'
net.ipv4.ip_forward = 1 net.ipv4.ip_forward = 1
net.ipv6.conf.all.forwarding = 1 net.ipv6.conf.all.forwarding = 1
@@ -365,13 +680,43 @@ EOF
sysctl --system >/dev/null 2>&1 || true sysctl --system >/dev/null 2>&1 || true
} }
setup_lxc_services() { systemd_unit_exists() {
log "Configuring LXC services..." unit="$1"
systemctl list-unit-files "$unit" >/dev/null 2>&1 || [ -e "/etc/systemd/system/$unit" ] || [ -e "/usr/lib/systemd/system/$unit" ] || [ -e "/lib/systemd/system/$unit" ]
}
systemd_enable_now_if_exists() {
unit="$1"
if systemd_unit_exists "$unit"; then
systemctl enable --now "$unit" >/dev/null 2>&1 || warn "服务 $unit 启动失败,将继续安装并在运行时降级处理。"
return
fi
log "未检测到 systemd 单元 $unit,跳过。"
}
systemd_existing_units() {
for unit in "$@"; do
if systemd_unit_exists "$unit"; then
printf ' %s' "$unit"
fi
done
}
setup_runtime_services() {
log "正在配置 LXC 和 KVM 服务..."
if is_systemd; then if is_systemd; then
systemctl enable --now lxcfs >/dev/null 2>&1 || true systemd_enable_now_if_exists lxcfs.service
systemctl enable --now lxc-net >/dev/null 2>&1 || true systemd_enable_now_if_exists lxc-net.service
systemctl enable --now lxc >/dev/null 2>&1 || true systemd_enable_now_if_exists lxc.service
if systemd_unit_exists libvirtd.service; then
systemd_enable_now_if_exists libvirtd.service
log "检测到 libvirt 传统 libvirtd 服务,已使用 libvirtd 模式。"
else
systemd_enable_now_if_exists virtqemud.service
systemd_enable_now_if_exists virtqemud.socket
fi
systemd_enable_now_if_exists virtlogd.socket
return return
fi fi
@@ -382,39 +727,102 @@ setup_lxc_services() {
rc-service lxc start >/dev/null 2>&1 || true rc-service lxc start >/dev/null 2>&1 || true
rc-update add lxcfs default >/dev/null 2>&1 || true rc-update add lxcfs default >/dev/null 2>&1 || true
rc-service lxcfs start >/dev/null 2>&1 || true rc-service lxcfs start >/dev/null 2>&1 || true
rc-update add dbus default >/dev/null 2>&1 || true
rc-service dbus start >/dev/null 2>&1 || true
rc-update add libvirtd default >/dev/null 2>&1 || true
rc-service libvirtd start >/dev/null 2>&1 || true
rc-update add virtlogd default >/dev/null 2>&1 || true
rc-service virtlogd start >/dev/null 2>&1 || true
return return
fi fi
die "No supported service manager found. CLICD supports systemd or OpenRC." die "未检测到支持的服务管理器。CLICD 当前支持 systemd OpenRC"
}
libvirt_network_active() {
virsh net-info default 2>/dev/null | awk -F: 'tolower($1) ~ /^[[:space:]]*active[[:space:]]*$/ {gsub(/^[ \t]+|[ \t]+$/, "", $2); print tolower($2)}' | grep -qx yes
}
setup_default_libvirt_network() {
if ! has_cmd virsh; then
warn "未找到 virsh,跳过 libvirt default NAT 网络检查。"
return
fi
log "正在检查 libvirt default NAT 网络..."
if ! virsh net-info default >/dev/null 2>&1; then
net_xml="$(mktemp /tmp/clicd-default-net.XXXXXX.xml)"
cat > "$net_xml" << 'EOF'
<network>
<name>default</name>
<bridge name='virbr0'/>
<forward mode='nat'/>
<ip address='192.168.122.1' netmask='255.255.255.0'>
<dhcp>
<range start='192.168.122.2' end='192.168.122.254'/>
</dhcp>
</ip>
</network>
EOF
virsh net-define "$net_xml"
rm -f "$net_xml"
fi
if ! libvirt_network_active; then
virsh net-start default
fi
virsh net-autostart default >/dev/null
if ! libvirt_network_active; then
die "libvirt default 网络仍未启动。请执行 virsh net-info default 查看详情。"
fi
log "libvirt default NAT 网络已启用。"
} }
setup_subids() { setup_subids() {
log "Setting up subordinate UID/GID ranges..." log "正在配置 subordinate UID/GID 范围..."
touch /etc/subuid /etc/subgid touch /etc/subuid /etc/subgid
grep -q '^root:' /etc/subuid 2>/dev/null || echo 'root:100000:65536' >> /etc/subuid grep -q '^root:' /etc/subuid 2>/dev/null || echo 'root:100000:65536' >> /etc/subuid
grep -q '^root:' /etc/subgid 2>/dev/null || echo 'root:100000:65536' >> /etc/subgid grep -q '^root:' /etc/subgid 2>/dev/null || echo 'root:100000:65536' >> /etc/subgid
} }
configure_lxc_storage_access() {
log "Configuring LXC storage directory permissions..."
mkdir -p /var/lib/lxc
chmod 755 /var/lib/lxc
}
try_enable_project_quota() { try_enable_project_quota() {
root_src="$(findmnt -no SOURCE / 2>/dev/null || true)" root_src="$(findmnt -no SOURCE / 2>/dev/null || true)"
root_fs="$(findmnt -no FSTYPE / 2>/dev/null || true)" root_fs="$(findmnt -no FSTYPE / 2>/dev/null || true)"
if [ "$root_fs" != "ext4" ] || [ -z "$root_src" ] || [ ! -b "$root_src" ]; then case "$root_fs" in
log "Project quota auto-enable skipped for root filesystem: ${root_fs:-unknown}" ext4)
;;
xfs|btrfs|zfs|overlay|unknown|"")
log "根文件系统 ${root_fs:-unknown} 不需要/不适合自动启用 ext4 project quotaCLICD 将使用兼容磁盘限制模式。"
return
;;
*)
log "根文件系统 ${root_fs:-unknown} 不在自动 project quota 支持范围,CLICD 将使用兼容磁盘限制模式。"
return
;;
esac
if [ -z "$root_src" ] || [ ! -b "$root_src" ]; then
log "根分区来源 ${root_src:-unknown} 不是块设备,跳过 project quota 自动检查,CLICD 将使用兼容磁盘限制模式。"
return return
fi fi
if ! has_cmd tune2fs; then if ! has_cmd tune2fs; then
log "Project quota auto-enable skipped because tune2fs is unavailable." log "未找到 tune2fs,跳过 project quota 检查,CLICD 将使用兼容磁盘限制模式。"
return return
fi fi
if tune2fs -l "$root_src" 2>/dev/null | grep -q 'project'; then if tune2fs -l "$root_src" 2>/dev/null | grep -q 'project'; then
log "Ext4 project quota support already appears to be enabled." log "检测到 ext4 project quota 已可用。"
return return
fi fi
log "Ext4 project quota is not enabled. Disk limits will fall back to loopback images." log "ext4 project quota 未启用,CLICD 将自动回退到 loopback 镜像磁盘限制模式。"
} }
download_release_if_needed() { download_release_if_needed() {
@@ -428,8 +836,8 @@ download_release_if_needed() {
download_url="https://github.com/${REPO}/releases/download/${CLICD_INSTALL_VERSION}/${ASSET}" download_url="https://github.com/${REPO}/releases/download/${CLICD_INSTALL_VERSION}/${ASSET}"
fi fi
log "clicd binary not found in current directory." log "当前目录未找到 clicd 二进制,将下载发行版包。"
log "Downloading release package: ${download_url}" log "正在下载发行版包:${download_url}"
tmp_dir="$(mktemp -d)" tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' 0 trap 'rm -rf "$tmp_dir"' 0
@@ -439,12 +847,12 @@ download_release_if_needed() {
elif has_cmd wget; then elif has_cmd wget; then
wget -O "$tmp_dir/$ASSET" "$download_url" wget -O "$tmp_dir/$ASSET" "$download_url"
else else
die "curl or wget is required to download the release package." die "下载发行版包需要 curl wget"
fi fi
tar -xzf "$tmp_dir/$ASSET" -C "$tmp_dir" tar -xzf "$tmp_dir/$ASSET" -C "$tmp_dir"
cd "$tmp_dir/clicd-linux-amd64" cd "$tmp_dir/clicd-linux-amd64"
[ -f "./clicd" ] || die "Downloaded release package did not contain clicd." [ -f "./clicd" ] || die "下载的发行版包中未找到 clicd 二进制。"
} }
install_binary() { install_binary() {
@@ -460,20 +868,28 @@ install_binary() {
chmod +x "$tmp_bin" chmod +x "$tmp_bin"
mv -f "$tmp_bin" /usr/local/bin/clicd mv -f "$tmp_bin" /usr/local/bin/clicd
chmod +x /usr/local/bin/clicd chmod +x /usr/local/bin/clicd
log "Installed binary: /usr/local/bin/clicd" log "已安装二进制:/usr/local/bin/clicd"
} }
install_systemd_service() { install_systemd_service() {
cat > /etc/systemd/system/clicd.service << 'EOF' libvirt_after="$(systemd_existing_units libvirtd.service virtqemud.service virtqemud.socket virtlogd.socket)"
libvirt_wants="$(systemd_existing_units libvirtd.service virtqemud.socket virtlogd.socket)"
lxc_after="$(systemd_existing_units lxc.service lxcfs.service lxc-net.service)"
cat > /etc/systemd/system/clicd.service << EOF
[Unit] [Unit]
Description=CLICD - LXC Container Manager Description=CLICD - LXC/KVM Container Manager
After=network.target lxc.service After=network-online.target${lxc_after}${libvirt_after}
Wants=network-online.target${libvirt_wants}
StartLimitIntervalSec=60
StartLimitBurst=10
[Service] [Service]
Type=simple Type=simple
ExecStart=/usr/local/bin/clicd server ExecStart=/usr/local/bin/clicd server
Restart=always Restart=always
RestartSec=5 RestartSec=5
LimitNOFILE=1048576
Environment=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin Environment=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
[Install] [Install]
@@ -490,7 +906,7 @@ install_openrc_service() {
#!/sbin/openrc-run #!/sbin/openrc-run
name="CLICD" name="CLICD"
description="CLICD - LXC Container Manager" description="CLICD - LXC/KVM Container Manager"
command="/usr/local/bin/clicd" command="/usr/local/bin/clicd"
command_args="server" command_args="server"
command_background=true command_background=true
@@ -500,7 +916,7 @@ error_log="/var/log/clicd.err"
depend() { depend() {
need net need net
after lxc after lxc libvirtd
} }
EOF EOF
@@ -510,51 +926,57 @@ EOF
} }
install_service() { install_service() {
log "Installing CLICD service..." log "正在安装 CLICD 服务..."
if is_systemd; then if is_systemd; then
install_systemd_service install_systemd_service
elif is_openrc; then elif is_openrc; then
install_openrc_service install_openrc_service
else else
die "No supported service manager found. CLICD supports systemd or OpenRC." die "未检测到支持的服务管理器。CLICD 当前支持 systemd OpenRC"
fi fi
} }
print_summary() { print_summary() {
echo "" echo ""
echo "=====================================" echo "====================================="
echo " Installation Complete" echo " 安装完成"
echo "=====================================" echo "====================================="
echo " Web: http://YOUR_SERVER_IP:8999" echo " Web 面板:http://YOUR_SERVER_IP:8999"
echo " Binary: /usr/local/bin/clicd" echo " 二进制:/usr/local/bin/clicd"
echo " 安装日志:$LOG_FILE"
echo " 问题反馈:$ISSUE_URL"
if is_systemd; then if is_systemd; then
echo " Service: systemctl {start|stop|restart|status} clicd" echo " 服务:systemctl {start|stop|restart|status} clicd"
echo " Logs: journalctl -u clicd -f" echo " 运行日志:journalctl -u clicd -f"
elif is_openrc; then elif is_openrc; then
echo " Service: rc-service clicd {start|stop|restart|status}" echo " 服务:rc-service clicd {start|stop|restart|status}"
echo " Logs: tail -f /var/log/clicd.log /var/log/clicd.err" echo " 运行日志:tail -f /var/log/clicd.log /var/log/clicd.err"
fi fi
echo "=====================================" echo "====================================="
echo "" echo ""
echo "Initial credentials, if this was the first run:" echo "首次安装时的初始账号信息:"
if is_systemd; then if is_systemd; then
journalctl -u clicd --no-pager -n 80 | grep -E "Username:|Password:" || true journalctl -u clicd --no-pager -n 80 | grep -E "Username:|Password:" || true
else else
grep -E "Username:|Password:" /var/log/clicd.log /var/log/clicd.err 2>/dev/null || true grep -E "Username:|Password:" /var/log/clicd.log /var/log/clicd.err 2>/dev/null || true
fi fi
echo "" echo ""
echo "If no password is shown, this server already had /root/.clicd/config.json." echo "如果没有显示密码,说明服务器已有 /root/.clicd/config.db。"
echo "The existing admin password cannot be recovered from the bcrypt hash." echo "已有管理员密码使用 bcrypt 存储,无法反查;请使用面板内修改密码或重置配置。"
} }
install_dependencies run_step "兼容性检查" check_os_compatibility
configure_kernel_networking run_step "存储环境检查" check_storage_compatibility
setup_lxc_services run_step "安装系统依赖" install_dependencies
setup_subids run_step "配置内核网络参数" configure_kernel_networking
try_enable_project_quota run_step "配置运行时服务" setup_runtime_services
download_release_if_needed run_step "配置 libvirt default NAT 网络" setup_default_libvirt_network
install_binary run_step "配置 UID/GID 映射" setup_subids
install_service run_step "Configure LXC storage permissions" 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
sleep 2 sleep 2
print_summary print_summary