Compare commits

...

61 Commits

Author SHA1 Message Date
MengMengCode a923daa7a2 release: v1.1.7 2026-06-09 13:06:46 +08:00
MengMengCode c46f84c66e 增加SSL支持HTTPS/WSS 2026-06-09 13:06:26 +08:00
MengMengCode 835bb51c6e 修改 2026-06-09 02:00:04 +08:00
MengMengCode 7aed51e86b gitignore优化 2026-06-09 01:59:54 +08:00
MengMengCode e364807fb9 docs 2026-06-09 00:49:53 +08:00
MengMengCode c63ce02709 docs 2026-06-09 00:44:00 +08:00
MengMengCode 54f9ed7f7d Merge branch 'main' of https://github.com/MengMengCode/CLICD 2026-06-09 00:25:06 +08:00
MengMengCode b01f9fe301 修复UI 2026-06-09 00:25:04 +08:00
Meng Meng d03e2c4c0c Create LICENSE 2026-06-09 00:23:55 +08:00
MengMengCode 33603f5776 release: v1.1.6 2026-06-09 00:12:19 +08:00
MengMengCode 9ad7bcc97a 完善API文档 2026-06-09 00:10:45 +08:00
MengMengCode f3a1687a18 修复了一些已知问题 2026-06-08 21:18:17 +08:00
MengMengCode 49b13af91c release: v1.1.5 2026-06-08 20:10:34 +08:00
MengMengCode e79609281f 增强API集成能力,划分KEY功能权限 2026-06-08 19:25:55 +08:00
MengMengCode 2fa130a2b6 标记并清理 CLICD 创建的 libvirt default 网络 2026-06-08 16:21:23 +08:00
MengMengCode 14d2192b05 完善卸载网络规则清理 2026-06-08 16:18:24 +08:00
MengMengCode 9f5ad94a83 清理卸载时的 LXC 镜像缓存 2026-06-08 16:10:55 +08:00
MengMengCode ac6587f2bc 增强安装脚本发行版下载回退 2026-06-08 16:03:32 +08:00
MengMengCode 6fad37b844 修复安装脚本下载失败处理 2026-06-08 15:55:17 +08:00
MengMengCode d0eb92eaab 修复 2026-06-08 15:49:42 +08:00
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
MengMengCode 422e48b524 release: v1.0.10 2026-06-06 15:53:14 +08:00
MengMengCode 3488b6db56 优化了一些功能 2026-06-06 15:52:27 +08:00
MengMengCode c7ba19fa34 release: v1.0.9 2026-06-06 15:10:54 +08:00
MengMengCode ffedf801e7 添加了子用户列表功能 2026-06-06 15:10:24 +08:00
MengMengCode 49d5a65357 release: v1.0.8 2026-06-06 12:57:46 +08:00
MengMengCode 8f32765ffe 修复了一些问题 2026-06-06 12:57:17 +08:00
99 changed files with 20125 additions and 2953 deletions
+65
View File
@@ -0,0 +1,65 @@
name: Deploy Docs
on:
push:
branches:
- main
- master
paths:
- "docs/**"
- ".github/workflows/pages.yml"
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: github-pages
cancel-in-progress: false
jobs:
build:
name: Build VitePress
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
cache: npm
cache-dependency-path: docs/package-lock.json
- name: Setup Pages
uses: actions/configure-pages@v5
- name: Install dependencies
working-directory: docs
run: npm ci
- name: Build docs
working-directory: docs
env:
VITEPRESS_BASE: /
run: npm run build
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: docs/.vitepress/dist
deploy:
name: Deploy GitHub Pages
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
+3
View File
@@ -58,9 +58,12 @@ backend/tmp/
*.swp
*.swo
*~
.claude/
# OS
.DS_Store
Thumbs.db
linux.txt
push-release.ps1
deploy.ps1
backend/clicd
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+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="Tailwind CSS" src="https://img.shields.io/badge/Tailwind_CSS-3-06B6D4?style=flat-square&logo=tailwindcss&logoColor=white">
<img alt="LXC" src="https://img.shields.io/badge/LXC-container-111111?style=flat-square">
<img alt="KVM" src="https://img.shields.io/badge/KVM-virtualization-EE0000?style=flat-square&logo=linux&logoColor=white">
</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 这类轻量镜像。
2. 支持 WebSSH 管理,可以在浏览器里一键进入容器终端,不需要手动复制 SSH 密码。
3. 支持子用户管理链接,管理员可以把指定容器分发给拼车用户,子用户只能管理自己被授权的容器
4. 支持设置 NAT4 端口数量、NAT 端口映射和协议限制,并支持分配公网 IPv6。IPv6 分配要求宿主机本身拥有可路由的 IPv6 地址段
5. 支持超售容量估算。宿主机控制页提供 KSM 合并、Swap 倾向和 cgroup v2 `memory.reclaim` 一次性回收能力;不会展示 LXC 下无实际通用效果的内存气球回收开关
6. 支持 API 接入,可以通过 API 完成容器、任务、镜像、端口、流量、安全告警等功能的自动化控制
7. 支持仅使用 CLI 管理。需要关闭 Web 控制台时,可以停止并禁用 systemd 服务,然后使用 `clicd cli --no-web` 进入命令行模式
8. 支持设置容器有效期。到期后容器会自动关机,子用户无法继续操作,只有管理员重新设置延期日期后才能恢复使用
9. 支持单向和双向网络流量限制。达到限制后容器会自动关机,避免流量超额
10. 内置基于 conntrack 的轻量安全告警。系统不会保存完整正常连接日志,但会对端口扫描、横向扫描、爆破倾向、SMTP 滥用、UDP 反射、挖矿端口、代理/VPN/Tor 等可疑行为生成告警并写入审计日志。
3. 支持设置 NAT4 端口数量、NAT 端口映射和协议限制,并支持分配公网 IPv6。IPv6 分配要求宿主机本身拥有可路由的 IPv6 地址段
4. 支持单向和双向网络流量限制。达到限制后容器会自动关机,避免流量超额
5. 支持设置容器有效期。到期后容器会自动关机,子用户无法继续操作,只有管理员重新设置延期日期后才能恢复使用
6. 内置基于 conntrack 的轻量安全告警。系统不会保存完整正常连接日志,但会对端口扫描、横向扫描、爆破倾向、SMTP 滥用、UDP 反射、挖矿端口、代理/VPN/Tor 等可疑行为生成告警并写入审计日志
7. 支持子用户管理链接,管理员可以把指定容器分发给拼车用户,子用户只能管理自己被授权的容器
8. 支持 API 接入,可以通过 API 完成容器、任务、镜像、端口、流量、安全告警等功能的自动化控制
9. 支持仅使用 CLI 管理。需要关闭 Web 控制台时,可以停止并禁用 systemd 服务,然后使用 `clicd cli --no-web` 进入命令行模式
## 技术栈
- 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
- Runtime: Linux, systemd, LXC
- Runtime: Linux, systemd, LXC, KVM/QEMU
- 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-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
<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
go 1.22.0
go 1.24.0
toolchain go1.24.5
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
golang.org/x/crypto v0.28.0
golang.org/x/term v0.28.0
golang.org/x/crypto v0.45.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/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
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/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg=
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
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=
+363 -124
View File
@@ -2,122 +2,293 @@ package api
import (
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"fmt"
"net"
"net/http"
"strconv"
"strings"
"time"
"clicd/internal/config"
"golang.org/x/crypto/argon2"
)
type ApiKey struct {
ID string `json:"id"`
Name string `json:"name"`
Key string `json:"key,omitempty"`
Prefix string `json:"prefix"`
IPWhitelist string `json:"ip_whitelist"`
CreatedAt string `json:"created_at"`
LastUsed string `json:"last_used"`
ID string `json:"id"`
Name string `json:"name"`
Key string `json:"key,omitempty"`
Prefix string `json:"prefix"`
IPWhitelist string `json:"ip_whitelist"`
CreatedAt string `json:"created_at"`
LastUsed string `json:"last_used"`
Scopes []string `json:"scopes,omitempty"`
ExpiresAt string `json:"expires_at,omitempty"`
Disabled bool `json:"disabled,omitempty"`
ContainerUUIDs []string `json:"container_uuids,omitempty"`
LastUsedIP string `json:"last_used_ip,omitempty"`
}
type apiKeyRequest struct {
Name string `json:"name"`
IPWhitelist string `json:"ip_whitelist"`
Scopes []string `json:"scopes"`
ExpiresAt string `json:"expires_at"`
Disabled bool `json:"disabled"`
ContainerUUIDs []string `json:"container_uuids"`
}
var defaultApiKeyScopes = []string{
"dashboard:read",
"container:read",
"task:read",
"image:read",
"snapshot:read",
"routing:read",
"ipv6:read",
"host:read",
}
// HandleApiKeys handles GET (list) and POST (create) for API keys
func HandleApiKeys(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
if !requireScope(w, r, "apikey:read") {
return
}
listApiKeys(w, r)
case http.MethodPost:
if !requireScope(w, r, "apikey:create") {
return
}
createApiKey(w, r)
default:
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
}
}
// HandleApiKeyDelete handles DELETE for a specific API key
// HandleApiKeyDelete handles PATCH and DELETE for a specific API key
func HandleApiKeyDelete(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
switch r.Method {
case http.MethodPatch:
if !requireScope(w, r, "apikey:update") {
return
}
updateApiKey(w, r)
case http.MethodDelete:
if !requireScope(w, r, "apikey:delete") {
return
}
deleteApiKey(w, r)
default:
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
keyID := strings.TrimPrefix(r.URL.Path, "/api/api-keys/")
if keyID == "" {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Key ID required"})
return
}
config.DeleteApiKey(keyID)
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "API key deleted"})
}
func apiKeyIDFromPath(path string) string {
path = strings.TrimPrefix(path, "/api/api-keys/")
path = strings.TrimPrefix(path, "/api/v1/api-keys/")
return strings.Trim(path, "/")
}
func listApiKeys(w http.ResponseWriter, r *http.Request) {
keys := make([]ApiKey, 0)
for _, k := range config.AppConfig.ApiKeys {
keys = append(keys, ApiKey{
ID: k.ID,
Name: k.Name,
Prefix: k.Prefix,
IPWhitelist: k.IPWhitelist,
CreatedAt: k.CreatedAt,
LastUsed: k.LastUsed,
})
keys = append(keys, apiKeyResponse(k))
}
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: keys})
}
func createApiKey(w http.ResponseWriter, r *http.Request) {
var req struct {
Name string `json:"name"`
IPWhitelist string `json:"ip_whitelist"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Name == "" {
var req apiKeyRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.Name) == "" {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Name is required"})
return
}
if req.ExpiresAt != "" && !validApiKeyTime(req.ExpiresAt) {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid expiration date"})
return
}
// Generate key: clicd_sk_ + 32 hex chars
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)
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")
scopes := normalizeRequestedScopes(req.Scopes, defaultApiKeyScopes)
key := config.ApiKeyConfig{
ID: generateShortID(),
Name: req.Name,
KeyHash: hashKey(rawKey),
Prefix: rawKey[:13] + "...",
IPWhitelist: strings.TrimSpace(req.IPWhitelist),
CreatedAt: now,
ID: generateShortID(),
Name: strings.TrimSpace(req.Name),
KeyHash: keyHash,
Prefix: rawKey[:13] + "...",
IPWhitelist: strings.TrimSpace(req.IPWhitelist),
CreatedAt: now,
Scopes: scopes,
ExpiresAt: strings.TrimSpace(req.ExpiresAt),
Disabled: req.Disabled,
ContainerUUIDs: normalizeStringSlice(req.ContainerUUIDs),
}
config.AppConfig.ApiKeys = append(config.AppConfig.ApiKeys, key)
config.SaveConfig()
if err := config.SaveConfig(); err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to save API key"})
return
}
auditRequest(r, "apikey.create", key.Name, "scopes="+strings.Join(key.Scopes, ","), true, "")
resp := apiKeyResponse(key)
resp.Key = rawKey
jsonResponse(w, http.StatusCreated, APIResponse{
Success: true,
Message: "API key created. Save this key now - it won't be shown again.",
Data: ApiKey{
ID: key.ID,
Name: key.Name,
Key: rawKey,
Prefix: key.Prefix,
IPWhitelist: key.IPWhitelist,
CreatedAt: key.CreatedAt,
},
Data: resp,
})
}
func updateApiKey(w http.ResponseWriter, r *http.Request) {
keyID := apiKeyIDFromPath(r.URL.Path)
if keyID == "" {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Key ID required"})
return
}
var req apiKeyRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
return
}
if req.ExpiresAt != "" && !validApiKeyTime(req.ExpiresAt) {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid expiration date"})
return
}
for i := range config.AppConfig.ApiKeys {
if config.AppConfig.ApiKeys[i].ID != keyID {
continue
}
if strings.TrimSpace(req.Name) != "" {
config.AppConfig.ApiKeys[i].Name = strings.TrimSpace(req.Name)
}
config.AppConfig.ApiKeys[i].IPWhitelist = strings.TrimSpace(req.IPWhitelist)
if len(req.Scopes) > 0 {
config.AppConfig.ApiKeys[i].Scopes = normalizeStringSlice(req.Scopes)
}
config.AppConfig.ApiKeys[i].ExpiresAt = strings.TrimSpace(req.ExpiresAt)
config.AppConfig.ApiKeys[i].Disabled = req.Disabled
config.AppConfig.ApiKeys[i].ContainerUUIDs = normalizeStringSlice(req.ContainerUUIDs)
if err := config.SaveConfig(); err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to save API key"})
return
}
auditRequest(r, "apikey.update", config.AppConfig.ApiKeys[i].Name, "scopes="+strings.Join(config.AppConfig.ApiKeys[i].Scopes, ","), true, "")
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: apiKeyResponse(config.AppConfig.ApiKeys[i])})
return
}
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "API key not found"})
}
func deleteApiKey(w http.ResponseWriter, r *http.Request) {
keyID := apiKeyIDFromPath(r.URL.Path)
if keyID == "" {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Key ID required"})
return
}
name := keyID
for _, k := range config.AppConfig.ApiKeys {
if k.ID == keyID {
name = k.Name
break
}
}
config.DeleteApiKey(keyID)
auditRequest(r, "apikey.delete", name, "", true, "")
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "API key deleted"})
}
func apiKeyResponse(k config.ApiKeyConfig) ApiKey {
return ApiKey{
ID: k.ID,
Name: k.Name,
Prefix: k.Prefix,
IPWhitelist: k.IPWhitelist,
CreatedAt: k.CreatedAt,
LastUsed: k.LastUsed,
Scopes: normalizeApiKeyScopes(k.Scopes),
ExpiresAt: k.ExpiresAt,
Disabled: k.Disabled,
ContainerUUIDs: k.ContainerUUIDs,
LastUsedIP: k.LastUsedIP,
}
}
func generateShortID() string {
b := make([]byte, 4)
rand.Read(b)
return hex.EncodeToString(b)
}
// hashKey creates a simple hash for storage (not reversible)
func hashKey(key string) string {
sum := sha256.Sum256([]byte(key))
return hex.EncodeToString(sum[:])
const (
apiKeyHashPrefix = "argon2id"
apiKeyHashTime = uint32(3)
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 {
@@ -128,20 +299,75 @@ func legacyHashKey(key string) string {
return hex.EncodeToString(b)
}
// validateApiKey checks if the given key is valid and IP is allowed
func validateApiKey(rawKey, clientIP string) bool {
hashed := hashKey(rawKey)
func matchApiKey(rawKey string) (idx int, needsRehash bool) {
legacyHashed := legacyHashKey(rawKey)
for _, k := range config.AppConfig.ApiKeys {
if subtle.ConstantTimeCompare([]byte(k.KeyHash), []byte(hashed)) == 1 ||
subtle.ConstantTimeCompare([]byte(k.KeyHash), []byte(legacyHashed)) == 1 {
if k.IPWhitelist == "" {
return true
}
return isIPAllowed(clientIP, k.IPWhitelist)
for i, k := range config.AppConfig.ApiKeys {
if verifyAPIKeyHash(rawKey, k.KeyHash) {
return i, false
}
if subtle.ConstantTimeCompare([]byte(k.KeyHash), []byte(legacyHashed)) == 1 {
return i, true
}
}
return false
return -1, false
}
// validateApiKey checks if the given key is valid and IP is allowed.
func validateApiKey(rawKey, clientIP string) bool {
_, ok := validateApiKeyDetails(rawKey, clientIP)
return ok
}
func validateApiKeyDetails(rawKey, clientIP string) (*config.ApiKeyConfig, bool) {
idx, needsRehash := matchApiKey(rawKey)
if idx < 0 {
return nil, false
}
k := &config.AppConfig.ApiKeys[idx]
if k.Disabled || apiKeyExpired(k.ExpiresAt) {
return nil, false
}
if clientIP != "" && k.IPWhitelist != "" && !isIPAllowed(clientIP, k.IPWhitelist) {
return nil, false
}
if needsRehash {
if newHash, err := hashAPIKey(rawKey); err == nil {
config.AppConfig.ApiKeys[idx].KeyHash = newHash
config.SaveConfig()
}
}
if len(k.Scopes) == 0 {
k.Scopes = []string{"*"}
}
return k, true
}
func validateApiKeyRequest(r *http.Request) (*config.ApiKeyConfig, bool) {
apiKey := apiKeyFromRequest(r)
if apiKey == "" {
return nil, false
}
key, ok := validateApiKeyDetails(apiKey, clientIP(r))
if !ok {
return nil, false
}
updateApiKeyLastUsedForKey(key, clientIP(r))
return key, true
}
func authContextFromAPIKey(key *config.ApiKeyConfig) AuthContext {
actor := "api:" + key.ID
if key.Name != "" {
actor = "api:" + key.Name
}
return AuthContext{
Type: authTypeAPIKey,
ApiKeyID: key.ID,
ApiKeyName: key.Name,
Actor: actor,
Scopes: normalizeApiKeyScopes(key.Scopes),
ContainerUUIDs: key.ContainerUUIDs,
}
}
func apiKeyFromRequest(r *http.Request) string {
@@ -156,23 +382,16 @@ func apiKeyFromRequest(r *http.Request) string {
}
func isValidApiKeyRequest(r *http.Request) bool {
apiKey := apiKeyFromRequest(r)
if apiKey == "" {
return false
}
if !validateApiKey(apiKey, clientIP(r)) {
return false
}
updateApiKeyLastUsed(apiKey)
return true
_, ok := validateApiKeyRequest(r)
return ok
}
// isIPAllowed checks if clientIP matches any entry in the whitelist
func isIPAllowed(clientIP, whitelist string) bool {
clientIP = strings.TrimSpace(clientIP)
// Strip port if present
if idx := strings.LastIndex(clientIP, ":"); idx > strings.LastIndex(clientIP, "]") {
clientIP = clientIP[:idx]
clientIP = normalizeIPString(clientIP)
client := net.ParseIP(clientIP)
if client == nil {
return false
}
for _, entry := range strings.Split(whitelist, "\n") {
entry = strings.TrimSpace(entry)
@@ -180,77 +399,97 @@ func isIPAllowed(clientIP, whitelist string) bool {
continue
}
if strings.Contains(entry, "/") {
// CIDR match
if ipInCIDR(clientIP, entry) {
_, network, err := net.ParseCIDR(entry)
if err == nil && network.Contains(client) {
return true
}
} else if entry == clientIP {
continue
}
if allowed := net.ParseIP(normalizeIPString(entry)); allowed != nil && allowed.Equal(client) {
return true
}
}
return false
}
func ipInCIDR(ipStr, cidr string) bool {
parts := strings.Split(cidr, "/")
if len(parts) != 2 {
return false
}
// Simple prefix match for IPv4
ip := netParseIP(ipStr)
cidrIP := netParseIP(parts[0])
if ip == nil || cidrIP == nil {
return false
}
bits, err := strconv.Atoi(parts[1])
if err != nil || bits < 0 || bits > 32 {
return false
}
mask := uint32(0xFFFFFFFF) << (32 - bits)
ipVal := ip4ToUint32(ip)
cidrVal := ip4ToUint32(cidrIP)
return (ipVal & mask) == (cidrVal & mask)
}
func netParseIP(s string) net.IP {
func normalizeIPString(s string) string {
s = strings.TrimSpace(s)
if idx := strings.LastIndex(s, ":"); idx > strings.LastIndex(s, "]") {
s = s[:idx]
if host, _, err := net.SplitHostPort(s); err == nil {
return strings.Trim(host, "[]")
}
return net.ParseIP(s)
return strings.Trim(s, "[]")
}
func ip4ToUint32(ip net.IP) uint32 {
ip = ip.To4()
if ip == nil {
return 0
}
return uint32(ip[0])<<24 | uint32(ip[1])<<16 | uint32(ip[2])<<8 | uint32(ip[3])
func ipInCIDR(ipStr, cidr string) bool {
ip := net.ParseIP(normalizeIPString(ipStr))
_, network, err := net.ParseCIDR(cidr)
return err == nil && ip != nil && network.Contains(ip)
}
// updateApiKeyLastUsed marks the key as recently used
// updateApiKeyLastUsed marks the key as recently used.
func updateApiKeyLastUsed(rawKey string) {
hashed := hashKey(rawKey)
now := time.Now().Format("2006-01-02 15:04:05")
for i := range config.AppConfig.ApiKeys {
if config.AppConfig.ApiKeys[i].KeyHash == hashed {
config.AppConfig.ApiKeys[i].LastUsed = now
config.SaveConfig()
return
}
key, ok := validateApiKeyDetails(rawKey, "")
if !ok {
return
}
updateApiKeyLastUsedForKey(key, "")
}
func updateApiKeyLastUsedForKey(key *config.ApiKeyConfig, ip string) {
key.LastUsed = time.Now().Format("2006-01-02 15:04:05")
if ip != "" {
key.LastUsedIP = ip
}
config.SaveConfig()
}
// ApiKeyMiddleware authenticates requests via X-API-Key header or Authorization bearer.
func ApiKeyMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
apiKey := apiKeyFromRequest(r)
if apiKey == "" || !validateApiKey(apiKey, clientIP(r)) {
key, ok := validateApiKeyRequest(r)
if !ok {
jsonResponse(w, http.StatusUnauthorized, APIResponse{Success: false, Message: "Invalid API key or IP not in whitelist"})
return
}
updateApiKeyLastUsed(apiKey)
next(w, r)
next(w, withAuthContext(r, authContextFromAPIKey(key)))
}
}
func normalizeApiKeyScopes(scopes []string) []string {
return normalizeRequestedScopes(scopes, []string{"*"})
}
func normalizeRequestedScopes(scopes []string, fallback []string) []string {
result := normalizeStringSlice(scopes)
if len(result) == 0 {
return append([]string(nil), fallback...)
}
return result
}
func normalizeStringSlice(values []string) []string {
seen := map[string]bool{}
result := make([]string, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" || seen[value] {
continue
}
seen[value] = true
result = append(result, value)
}
return result
}
func validApiKeyTime(value string) bool {
_, err := time.ParseInLocation("2006-01-02 15:04:05", value, time.Local)
return err == nil
}
func apiKeyExpired(value string) bool {
if strings.TrimSpace(value) == "" {
return false
}
expiresAt, err := time.ParseInLocation("2006-01-02 15:04:05", value, time.Local)
return err == nil && !time.Now().Before(expiresAt)
}
+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")
}
}
+186 -4
View File
@@ -1,6 +1,7 @@
package api
import (
"context"
"encoding/json"
"net/http"
"strings"
@@ -28,6 +29,132 @@ type APIResponse struct {
Data interface{} `json:"data,omitempty"`
}
type authContextKey struct{}
type AuthContext struct {
Type string
Username string
ApiKeyID string
ApiKeyName string
Actor string
Scopes []string
ContainerUUIDs []string
}
const (
authTypeAdmin = "admin"
authTypeSubUser = "sub_user"
authTypeAPIKey = "api_key"
)
func withAuthContext(r *http.Request, auth AuthContext) *http.Request {
return r.WithContext(context.WithValue(r.Context(), authContextKey{}, auth))
}
func authContextFromRequest(r *http.Request) (AuthContext, bool) {
ctx, ok := r.Context().Value(authContextKey{}).(AuthContext)
return ctx, ok
}
func requestActor(r *http.Request) string {
if ctx, ok := authContextFromRequest(r); ok && ctx.Actor != "" {
return ctx.Actor
}
if claims, ok := claimsFromRequest(r); ok {
if subUser, _ := claims["sub_user"].(string); subUser != "" {
return "user:" + subUser
}
if username, _ := claims["username"].(string); username != "" {
return username
}
}
return "admin"
}
func hasScope(r *http.Request, scope string) bool {
ctx, ok := authContextFromRequest(r)
if !ok {
return true
}
switch ctx.Type {
case authTypeAdmin:
return true
case authTypeSubUser:
return subUserScopeAllowed(scope)
case authTypeAPIKey:
return scopeAllowed(ctx.Scopes, scope)
default:
return false
}
}
func subUserScopeAllowed(scope string) bool {
switch scope {
case "container:read", "container:power", "container:reinstall", "container:network",
"dashboard:read", "image:read", "task:read", "snapshot:read", "snapshot:create", "snapshot:delete", "snapshot:restore", "snapshot:schedule",
"terminal:ssh", "terminal:vnc":
return true
default:
return false
}
}
func hasAnyScope(r *http.Request, scopes ...string) bool {
for _, scope := range scopes {
if hasScope(r, scope) {
return true
}
}
return false
}
func scopeAllowed(scopes []string, required string) bool {
for _, scope := range scopes {
scope = strings.TrimSpace(scope)
if scope == "*" || scope == "admin:*" || scope == required {
return true
}
if strings.HasSuffix(scope, ":*") {
prefix := strings.TrimSuffix(scope, "*")
if strings.HasPrefix(required, prefix) {
return true
}
}
}
return false
}
func requireScope(w http.ResponseWriter, r *http.Request, scope string) bool {
if hasScope(r, scope) {
return true
}
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Insufficient API key scope"})
return false
}
func ScopeMiddleware(scope string, next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !requireScope(w, r, scope) {
return
}
next(w, r)
}
}
func AnyScopeMiddleware(scopes []string, next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if hasAnyScope(r, scopes...) {
next(w, r)
return
}
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Insufficient API key scope"})
}
}
func auditRequest(r *http.Request, action, target, detail string, success bool, errMsg string) {
config.AddAuditLogFull(action, target, detail, requestActor(r), clientIP(r), r.UserAgent(), success, errMsg)
}
func jsonResponse(w http.ResponseWriter, status int, resp APIResponse) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
@@ -67,6 +194,32 @@ func claimsFromToken(tokenString string) (jwt.MapClaims, bool) {
return nil, false
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
return nil, false
}
// For sub-user tokens, check token_version against stored version (password rotation invalidation)
if subUser, _ := claims["sub_user"].(string); subUser != "" {
tokenVersionFloat, hasVersion := claims["token_version"].(float64)
tokenVersion := int(tokenVersionFloat)
foundSubUser := false
for i := range config.AppConfig.SubUsers {
if config.AppConfig.SubUsers[i].Username == subUser {
foundSubUser = true
stored := config.AppConfig.SubUsers[i].TokenVersion
// If stored version > 0, require token_version to match exactly.
// This also rejects legacy tokens that lack token_version entirely.
if stored > 0 && (!hasVersion || tokenVersion != stored) {
return nil, false
}
break
}
}
if !foundSubUser {
return nil, false
}
}
return claims, ok
}
@@ -75,6 +228,9 @@ func claimsFromRequest(r *http.Request) (jwt.MapClaims, bool) {
}
func isSubUserRequest(r *http.Request) bool {
if ctx, ok := authContextFromRequest(r); ok {
return ctx.Type == authTypeSubUser
}
claims, ok := claimsFromRequest(r)
if !ok {
return false
@@ -189,19 +345,45 @@ func HandleCheckAuth(w http.ResponseWriter, r *http.Request) {
func AuthMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
tokenString := tokenFromRequest(r)
if !isValidToken(tokenString) && !isValidApiKeyRequest(r) {
jsonResponse(w, http.StatusUnauthorized, APIResponse{Success: false, Message: "Authentication required"})
if claims, ok := claimsFromToken(tokenString); ok {
if subUser, _ := claims["sub_user"].(string); subUser != "" {
auth := AuthContext{Type: authTypeSubUser, Username: subUser, Actor: "user:" + subUser}
if values, ok := claims["container_uuids"].([]interface{}); ok {
for _, value := range values {
if uuid, ok := value.(string); ok {
auth.ContainerUUIDs = append(auth.ContainerUUIDs, uuid)
}
}
}
next(w, withAuthContext(r, auth))
return
}
username, _ := claims["username"].(string)
if username == "" {
username = config.AppConfig.AdminUser
}
next(w, withAuthContext(r, AuthContext{Type: authTypeAdmin, Username: username, Actor: username}))
return
}
next(w, r)
if key, ok := validateApiKeyRequest(r); ok {
next(w, withAuthContext(r, authContextFromAPIKey(key)))
return
}
jsonResponse(w, http.StatusUnauthorized, APIResponse{Success: false, Message: "Authentication required"})
}
}
// AdminMiddleware requires a valid administrator token and rejects sub-user tokens.
func AdminMiddleware(next http.HandlerFunc) http.HandlerFunc {
return AuthMiddleware(func(w http.ResponseWriter, r *http.Request) {
if isSubUserRequest(r) {
ctx, _ := authContextFromRequest(r)
if ctx.Type == authTypeSubUser {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Administrator permission required"})
return
}
if ctx.Type == authTypeAPIKey && !scopeAllowed(ctx.Scopes, "admin:access") {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Administrator permission required"})
return
}
@@ -0,0 +1,7 @@
//go:build !linux
package api
func getRootDiskInfo() (DiskInfo, bool) {
return DiskInfo{}, false
}
+21
View File
@@ -0,0 +1,21 @@
//go:build linux
package api
import "golang.org/x/sys/unix"
func getRootDiskInfo() (DiskInfo, bool) {
var stat unix.Statfs_t
if err := unix.Statfs("/", &stat); err != nil {
return DiskInfo{}, false
}
total := float64(int64(stat.Blocks)*int64(stat.Bsize)) / (1024 * 1024 * 1024)
free := float64(int64(stat.Bavail)*int64(stat.Bsize)) / (1024 * 1024 * 1024)
return DiskInfo{
TotalGB: total,
UsedGB: total - free,
FreeGB: free,
}, true
}
+186 -24
View File
@@ -2,10 +2,12 @@ package api
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"unicode"
"clicd/internal/config"
"clicd/internal/lxc"
@@ -18,67 +20,174 @@ var lxcManager = lxc.NewManager()
func HandleContainers(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
if !requireScope(w, r, "container:read") {
return
}
listContainers(w, r)
case http.MethodPost:
if !requireScope(w, r, "container:create") {
return
}
if isAccessRestrictedRequest(r) {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Container-bound API keys cannot create containers"})
return
}
createContainer(w, r)
default:
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
}
}
// HandleSingleContainer handles individual container operations by ID or name: /api/containers/{id-or-name}/...
func HandleSingleContainer(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/api/containers/")
parts := strings.SplitN(path, "/", 2)
c := containerByIdentifier(parts[0])
if c == nil {
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"})
// HandleContainerListAlias supports legacy integrations that call
// /api/containers/list or /api/v1/containers/list.
func HandleContainerListAlias(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodPost {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
id := c.ID
if !requireScope(w, r, "container:read") {
return
}
listContainers(w, r)
}
// HandleSingleContainer handles individual container operations by ID or name: /api/containers/{id-or-name}/...
func HandleSingleContainer(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/api/v1/containers/")
path = strings.TrimPrefix(path, "/api/containers/")
parts := strings.SplitN(path, "/", 2)
c := containerByIdentifier(parts[0])
id := 0
if c != nil {
id = c.ID
}
action := ""
if len(parts) > 1 {
action = parts[1]
}
// Snapshot delete/restore operations: allow even if the container was deleted
isSnapshotDelete := strings.HasPrefix(action, "snapshots/") && r.Method == http.MethodDelete
isSnapshotRestore := strings.HasPrefix(action, "snapshots/") && strings.HasSuffix(action, "/restore") && r.Method == http.MethodPost
isSnapshotAction := isSnapshotDelete || isSnapshotRestore
if !isSnapshotAction && c == nil {
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"})
return
}
if !isSnapshotAction && !isContainerAllowedForRequest(r, parts[0]) {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Access denied to this container"})
return
}
if isSnapshotAction && id == 0 {
// For orphaned snapshots, resolve containerID from the snapshot itself
snapshotID := strings.TrimPrefix(action, "snapshots/")
snapshotID = strings.TrimSuffix(snapshotID, "/restore")
snapshot := config.FindSnapshot(snapshotID)
if snapshot == nil {
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Snapshot not found"})
return
}
id = snapshot.ContainerID
}
if isSnapshotAction {
if c := config.FindContainer(id); c != nil && !isContainerAllowedForRequest(r, c.UUID) {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Access denied to this container"})
return
}
}
switch {
case action == "start" && r.Method == http.MethodPost:
if !requireScope(w, r, "container:power") {
return
}
HandleSingleTaskAction(w, r, id, "start")
case action == "stop" && r.Method == http.MethodPost:
if !requireScope(w, r, "container:power") {
return
}
HandleSingleTaskAction(w, r, id, "stop")
case action == "restart" && r.Method == http.MethodPost:
if !requireScope(w, r, "container:power") {
return
}
HandleSingleTaskAction(w, r, id, "restart")
case action == "reinstall" && r.Method == http.MethodPost:
if !requireScope(w, r, "container:reinstall") {
return
}
HandleSingleTaskAction(w, r, id, "reinstall")
case action == "delete" && r.Method == http.MethodDelete:
if !requireScope(w, r, "container:delete") {
return
}
HandleSingleTaskAction(w, r, id, "delete")
case action == "reset-password" && r.Method == http.MethodPost:
if !requireScope(w, r, "container:password") {
return
}
resetSSHPassword(w, r, id)
case action == "usage" && r.Method == http.MethodGet:
if !requireScope(w, r, "container:read") {
return
}
getUsage(w, r, id)
case action == "traffic" && r.Method == http.MethodGet:
if !requireScope(w, r, "container:read") {
return
}
getTraffic(w, r, id)
case action == "traffic-reset" && r.Method == http.MethodPost:
if !requireScope(w, r, "container:traffic") {
return
}
resetTraffic(w, r, id)
case action == "traffic-limit" && r.Method == http.MethodPut:
if !requireScope(w, r, "container:traffic") {
return
}
updateTrafficLimit(w, r, id)
case action == "resource-limit" && r.Method == http.MethodPut:
if !requireScope(w, r, "container:resize") {
return
}
updateResourceLimit(w, r, id)
case action == "random-port" && r.Method == http.MethodGet:
if !requireScope(w, r, "container:network") {
return
}
getRandomPort(w, r, id)
case action == "expiry" && r.Method == http.MethodPut:
if !requireScope(w, r, "container:resize") {
return
}
updateExpiry(w, r, id)
case action == "ipv6" && r.Method == http.MethodPost:
if !requireScope(w, r, "ipv6:assign") {
return
}
assignIPv6(w, r, id)
case action == "snapshots" || strings.HasPrefix(action, "snapshots/"):
handleContainerSnapshots(w, r, id, action)
case action == "port-mappings" && r.Method == http.MethodPost:
if !requireScope(w, r, "container:network") {
return
}
addPortMapping(w, r, id)
case strings.HasPrefix(action, "port-mappings/") && r.Method == http.MethodPut:
if !requireScope(w, r, "container:network") {
return
}
updatePortMapping(w, r, id, strings.TrimPrefix(action, "port-mappings/"))
case strings.HasPrefix(action, "port-mappings/") && r.Method == http.MethodDelete:
if !requireScope(w, r, "container:network") {
return
}
deletePortMapping(w, r, id, strings.TrimPrefix(action, "port-mappings/"))
case r.Method == http.MethodGet:
if !requireScope(w, r, "container:read") {
return
}
getContainer(w, r, id)
default:
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Action not found"})
@@ -86,10 +195,7 @@ func HandleSingleContainer(w http.ResponseWriter, r *http.Request) {
}
func listContainers(w http.ResponseWriter, r *http.Request) {
containers, err := lxcManager.ListContainers()
if err != nil {
containers = config.AppConfig.Containers
}
containers, _ := listByRuntime()
containers = filterContainersForRequest(r, containers)
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: containers})
}
@@ -104,11 +210,12 @@ func createContainer(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Container name is required"})
return
}
cfg.Virtualization = runtimeFromRequest(cfg.Virtualization)
if cfg.TemplateID == "" {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Template is required"})
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"})
return
}
@@ -131,7 +238,7 @@ func createContainer(w http.ResponseWriter, r *http.Request) {
if cfg.SnapshotLimit <= 0 {
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()})
return
}
@@ -147,7 +254,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()})
return
}
@@ -160,11 +267,15 @@ func getContainer(w http.ResponseWriter, r *http.Request, id int) {
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"})
return
}
if c.IsKVM() && c.Status == "running" {
_, _ = kvmManager.RefreshVNCPort(c.ID)
_, _ = kvmManager.RefreshNetwork(c.ID)
}
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: c})
}
func getUsage(w http.ResponseWriter, r *http.Request, id int) {
usage, err := lxcManager.GetResourceUsage(id)
usage, err := usageByRuntime(id)
if err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
return
@@ -173,7 +284,7 @@ func getUsage(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 {
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"})
return
@@ -262,7 +373,7 @@ func updateResourceLimit(w http.ResponseWriter, r *http.Request, id int) {
if req.RAMMB > 0 {
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()})
return
}
@@ -275,13 +386,17 @@ func updateResourceLimit(w http.ResponseWriter, r *http.Request, id int) {
// Re-apply resource limits to running container
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()})
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) {
@@ -321,6 +436,9 @@ func HandleTemplates(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
if !requireScope(w, r, "image:read") {
return
}
if isSubUserRequest(r) {
HandleEnabledImages(w, r)
return
@@ -335,10 +453,11 @@ func HandleDashboard(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
containers, err := lxcManager.ListContainers()
if err != nil {
containers = config.AppConfig.Containers
if !requireScope(w, r, "dashboard:read") {
return
}
containers, _ := listByRuntime()
containers = filterContainersForRequest(r, containers)
running := 0
stopped := 0
for _, c := range containers {
@@ -362,6 +481,9 @@ func HandleHostInfo(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
if !requireScope(w, r, "host:read") {
return
}
info := getHostInfo()
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: info})
}
@@ -372,7 +494,24 @@ func resetSSHPassword(w http.ResponseWriter, r *http.Request, id int) {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "容器已到期,不允许此操作"})
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 {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
return
@@ -384,6 +523,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) {
var pm config.PortMapping
if err := json.NewDecoder(r.Body).Decode(&pm); err != nil {
File diff suppressed because it is too large Load Diff
+372 -66
View File
@@ -1,6 +1,7 @@
package api
import (
"context"
"encoding/json"
"fmt"
"net/http"
@@ -8,27 +9,152 @@ import (
"os/exec"
"path/filepath"
"sync"
"time"
"clicd/internal/config"
"clicd/internal/kvm"
"clicd/internal/lxc"
)
// ImageInfo represents a template image with its download/enable status.
type ImageInfo 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"`
Downloaded bool `json:"downloaded"`
Enabled bool `json:"enabled"`
Downloading bool `json:"downloading"`
SizeBytes int64 `json:"size_bytes"`
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Distro string `json:"distro"`
Release string `json:"release"`
Arch string `json:"arch"`
Description string `json:"description"`
Downloaded bool `json:"downloaded"`
Enabled bool `json:"enabled"`
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 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.
func isImageDownloaded(distro, release, arch string) bool {
@@ -78,6 +204,9 @@ func getEnabledImageSet() map[string]bool {
for _, t := range lxc.GetTemplates() {
set[t.ID] = true
}
for _, t := range kvm.GetImages() {
set[t.ID] = true
}
} else {
for _, id := range config.AppConfig.EnabledImages {
set[id] = true
@@ -92,37 +221,78 @@ func HandleImages(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
if !requireScope(w, r, "image:read") {
return
}
enabledSet := getEnabledImageSet()
cleanupOldImageDownloadErrors()
templates := lxc.GetTemplates()
enabledSet := getEnabledImageSet()
images := make([]ImageInfo, 0, len(templates))
images := make([]ImageInfo, 0, len(templates)+len(kvm.GetImages()))
for _, t := range templates {
_, downloading := imageDownloads[t.ID]
dl := imageDownloadInfo(t.ID)
downloaded, size := imageDownloadedInfo(t.Distro, t.Release, t.Arch)
images = append(images, ImageInfo{
ID: t.ID,
Name: t.Name,
Distro: t.Distro,
Release: t.Release,
Arch: t.Arch,
Description: t.Description,
Downloaded: downloaded,
Enabled: enabledSet[t.ID],
Downloading: downloading,
SizeBytes: size,
ID: t.ID,
Name: t.Name,
Type: config.VirtualizationLXC,
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,
})
}
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})
}
// 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) {
if r.Method != http.MethodPost {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
if !requireScope(w, r, "image:download") {
return
}
var req struct {
TemplateID string `json:"template_id"`
@@ -134,59 +304,137 @@ func HandleImageDownload(w http.ResponseWriter, r *http.Request) {
tmpl := lxc.FindTemplate(req.TemplateID)
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
}
// Already downloaded? Just enable if needed.
if isImageDownloaded(tmpl.Distro, tmpl.Release, tmpl.Arch) {
ensureImageEnabled(tmpl.ID)
clearImageDownload(tmpl.ID)
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Already downloaded"})
return
}
// Already downloading?
imageDownloadsMu.Lock()
if imageDownloads[req.TemplateID] {
imageDownloadsMu.Unlock()
ctx, ok := startImageDownload(tmpl.ID, "lxc-create")
if !ok {
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: "Already downloading"})
return
}
imageDownloads[req.TemplateID] = true
imageDownloadsMu.Unlock()
defer func() {
imageDownloadsMu.Lock()
delete(imageDownloads, req.TemplateID)
imageDownloadsMu.Unlock()
}()
// Auto-enable on download
ensureImageEnabled(tmpl.ID)
// Download via lxc-create with a temp container, then destroy it.
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)),
go func(tmpl lxc.Template) {
// Download via lxc-create with a temp container, then destroy it.
tmpName := lxcImageDownloadTempName(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)
}
updateImageDownload(tmpl.ID, func(st *imageDownloadStatus) {
st.Stage = "lxc-create"
})
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
}
if !requireScope(w, r, "image:download") {
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
}
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.
@@ -195,6 +443,9 @@ func HandleImageDelete(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
if !requireScope(w, r, "image:delete") {
return
}
var req struct {
TemplateID string `json:"template_id"`
@@ -203,9 +454,22 @@ func HandleImageDelete(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "template_id required"})
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)
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"})
return
}
@@ -232,6 +496,9 @@ func HandleImageToggle(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
if !requireScope(w, r, "image:toggle") {
return
}
var req struct {
TemplateID string `json:"template_id"`
@@ -258,14 +525,31 @@ func HandleEnabledImages(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
if !requireScope(w, r, "image:read") {
return
}
templates := lxc.GetTemplates()
runtime := runtimeFromRequest(r.URL.Query().Get("type"))
enabledSet := getEnabledImageSet()
result := make([]lxc.Template, 0)
for _, t := range templates {
if enabledSet[t.ID] && isImageDownloaded(t.Distro, t.Release, t.Arch) {
result = append(result, t)
result := make([]map[string]string, 0)
if runtime == config.VirtualizationKVM {
for _, t := range kvm.GetImages() {
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 +557,20 @@ func HandleEnabledImages(w http.ResponseWriter, r *http.Request) {
}
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)
if tmpl == nil {
return false
@@ -288,6 +586,9 @@ func ensureImageEnabled(id string) {
for _, t := range lxc.GetTemplates() {
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()
return // Already contains all IDs including this one
}
@@ -313,6 +614,11 @@ func removeImageEnabled(id string) {
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()
return
}
+4 -1
View File
@@ -7,12 +7,15 @@ func HandleIPv6Status(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
if !requireScope(w, r, "ipv6:read") {
return
}
status := lxcManager.DetectIPv6Status()
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: status})
}
func assignIPv6(w http.ResponseWriter, r *http.Request, id int) {
c, err := lxcManager.AssignIPv6(id)
c, err := assignIPv6ByRuntime(id)
if err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
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
}
+3
View File
@@ -50,6 +50,9 @@ func HandleRouting(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
if !requireScope(w, r, "routing:read") {
return
}
nat4Mappings := make([]nat4Route, 0)
usedPorts := map[int]bool{}
+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)
}
+215 -18
View File
@@ -7,6 +7,7 @@ import (
"net/http"
"os"
"os/exec"
"sort"
"strconv"
"strings"
"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) {
ss.mu.Lock()
defer ss.mu.Unlock()
now := time.Now()
cutoff := now.Add(-5 * time.Minute)
shouldShutdown := false
for i := range ss.alerts {
a := &ss.alerts[i]
@@ -579,6 +580,11 @@ func (ss *SecurityScanner) addAlert(name, alertType, severity, srcIP, dstIP stri
if severityRank(severity) > severityRank(a.Severity) {
a.Severity = severity
}
shouldShutdown = config.AppConfig.SecurityAutoShutdown
ss.mu.Unlock()
if shouldShutdown {
autoShutdownAlertContainer(name, alertType, severity)
}
return
}
@@ -599,10 +605,16 @@ func (ss *SecurityScanner) addAlert(name, alertType, severity, srcIP, dstIP stri
ss.alerts = append(ss.alerts, alert)
config.AddAuditLog("security_"+alertType, name, fmt.Sprintf("[%s] %s", severity, detail), "system")
shouldShutdown = config.AppConfig.SecurityAutoShutdown
if 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 {
@@ -620,26 +632,68 @@ 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.
func HandleSecurityAlerts(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
ss := ensureScanner()
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 {
reversed = []SecurityAlert{}
if !requireScope(w, r, "security:read") {
return
}
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: reversed})
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: filterSecurityAlertsForRequest(r, mergedSecurityAlerts())})
}
// HandleSecuritySettings returns or updates security automation settings.
func HandleSecuritySettings(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
if !requireScope(w, r, "security:read") {
return
}
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]bool{
"auto_shutdown": config.AppConfig.SecurityAutoShutdown,
}})
case http.MethodPut:
if !requireScope(w, r, "security:settings") {
return
}
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
}
auditRequest(r, "security.settings", "auto_shutdown", fmt.Sprintf("auto_shutdown=%v", req.AutoShutdown), true, "")
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]bool{
"auto_shutdown": config.AppConfig.SecurityAutoShutdown,
}})
default:
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
}
}
// HandleSecurityCheck triggers immediate security check for a container.
@@ -648,6 +702,9 @@ func HandleSecurityCheck(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
if !requireScope(w, r, "security:check") {
return
}
var req struct {
ContainerName string `json:"container_name"`
@@ -662,6 +719,10 @@ func HandleSecurityCheck(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found or not running"})
return
}
if !isContainerAllowedForRequest(r, c.UUID) {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Access denied to this container"})
return
}
ensureScanner().checkContainer(c.Name, c.IP)
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Security check completed"})
@@ -673,6 +734,9 @@ func HandleSecurityLogs(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
if !requireScope(w, r, "security:read") {
return
}
containerName := r.URL.Query().Get("container")
if containerName == "" {
@@ -685,6 +749,10 @@ func HandleSecurityLogs(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: []map[string]interface{}{}})
return
}
if !isContainerAllowedForRequest(r, c.UUID) {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Access denied to this container"})
return
}
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: getConnectionLogs(c.IP)})
}
@@ -737,14 +805,16 @@ func HandleContainerSecuritySummary(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
if !requireScope(w, r, "security:read") {
return
}
ss := ensureScanner()
ss.mu.Lock()
critical := 0
high := 0
medium := 0
low := 0
for _, a := range ss.alerts {
alerts := filterSecurityAlertsForRequest(r, mergedSecurityAlerts())
for _, a := range alerts {
switch a.Severity {
case "critical":
critical++
@@ -756,8 +826,7 @@ func HandleContainerSecuritySummary(w http.ResponseWriter, r *http.Request) {
low++
}
}
total := len(ss.alerts)
ss.mu.Unlock()
total := len(alerts)
summary := map[string]interface{}{
"total_alerts": total,
@@ -769,3 +838,131 @@ func HandleContainerSecuritySummary(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: summary})
}
func filterSecurityAlertsForRequest(r *http.Request, alerts []SecurityAlert) []SecurityAlert {
allowed, restricted := requestAllowedContainers(r)
if !restricted {
return alerts
}
filtered := make([]SecurityAlert, 0, len(alerts))
for _, alert := range alerts {
if c := config.FindContainerByName(alert.ContainerName); c != nil && isContainerAllowed(allowed, c) {
filtered = append(filtered, alert)
}
}
return filtered
}
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
}
+3
View File
@@ -56,6 +56,9 @@ func HandleLoginLogs(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
if !requireScope(w, r, "loginlog:read") {
return
}
// Return in reverse (newest first)
reversed := make([]LoginLog, len(loginLogs))
+41 -13
View File
@@ -16,7 +16,11 @@ func HandleSnapshots(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
if !requireScope(w, r, "snapshot:read") {
return
}
snapshots := append([]config.Snapshot(nil), config.AppConfig.Snapshots...)
snapshots = filterSnapshotsForRequest(r, snapshots)
sortSnapshotsNewestFirst(snapshots)
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: snapshots})
}
@@ -24,17 +28,35 @@ func HandleSnapshots(w http.ResponseWriter, r *http.Request) {
func handleContainerSnapshots(w http.ResponseWriter, r *http.Request, containerID int, action string) {
switch {
case action == "snapshots" && r.Method == http.MethodGet:
if !requireScope(w, r, "snapshot:read") {
return
}
listContainerSnapshots(w, r, containerID)
case action == "snapshots" && r.Method == http.MethodPost:
if !requireScope(w, r, "snapshot:create") {
return
}
createContainerSnapshot(w, r, containerID)
case action == "snapshots/schedule" && r.Method == http.MethodPost:
if !requireScope(w, r, "snapshot:schedule") {
return
}
updateSnapshotSchedule(w, r, containerID)
case action == "snapshots/quota" && r.Method == http.MethodPut:
if !requireScope(w, r, "snapshot:schedule") {
return
}
updateSnapshotQuota(w, r, containerID)
case strings.HasPrefix(action, "snapshots/") && strings.HasSuffix(action, "/restore") && r.Method == http.MethodPost:
if !requireScope(w, r, "snapshot:restore") {
return
}
snapshotID := strings.TrimSuffix(strings.TrimPrefix(action, "snapshots/"), "/restore")
restoreContainerSnapshot(w, r, containerID, snapshotID)
case strings.HasPrefix(action, "snapshots/") && r.Method == http.MethodDelete:
if !requireScope(w, r, "snapshot:delete") {
return
}
snapshotID := strings.TrimPrefix(action, "snapshots/")
deleteContainerSnapshot(w, r, containerID, snapshotID)
default:
@@ -74,7 +96,7 @@ func createContainerSnapshot(w http.ResponseWriter, r *http.Request, containerID
return
}
}
snapshot, err := lxcManager.CreateSnapshot(containerID, user, false, 0)
snapshot, err := createSnapshotByRuntime(containerID, user, false, 0)
if err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
return
@@ -138,7 +160,7 @@ func updateSnapshotSchedule(w http.ResponseWriter, r *http.Request, containerID
req.Time = "03:00"
}
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 {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
return
@@ -162,7 +184,7 @@ func deleteContainerSnapshot(w http.ResponseWriter, r *http.Request, containerID
return
}
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()})
return
}
@@ -177,7 +199,7 @@ func restoreContainerSnapshot(w http.ResponseWriter, r *http.Request, containerI
return
}
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()})
return
}
@@ -186,15 +208,7 @@ func restoreContainerSnapshot(w http.ResponseWriter, r *http.Request, containerI
}
func requestUser(r *http.Request) string {
if claims, ok := claimsFromRequest(r); ok {
if subUser, _ := claims["sub_user"].(string); subUser != "" {
return "user:" + subUser
}
if username, _ := claims["username"].(string); username != "" {
return username
}
}
return "admin"
return requestActor(r)
}
func sortSnapshotsNewestFirst(snapshots []config.Snapshot) {
@@ -204,3 +218,17 @@ func sortSnapshotsNewestFirst(snapshots []config.Snapshot) {
return tj.Before(ti)
})
}
func filterSnapshotsForRequest(r *http.Request, snapshots []config.Snapshot) []config.Snapshot {
allowed, restricted := requestAllowedContainers(r)
if !restricted {
return snapshots
}
filtered := make([]config.Snapshot, 0, len(snapshots))
for _, snapshot := range snapshots {
if c := config.FindContainer(snapshot.ContainerID); c != nil && isContainerAllowed(allowed, c) {
filtered = append(filtered, snapshot)
}
}
return filtered
}
+82 -26
View File
@@ -27,6 +27,7 @@ type terminalResizeMessage struct {
type webSSHTicket struct {
ContainerName string
SubUser bool
ExpiresAt time.Time
}
@@ -41,6 +42,9 @@ func HandleWebSSHTicket(w http.ResponseWriter, r *http.Request) {
return
}
if !requireScope(w, r, "terminal:ssh") {
return
}
var req struct {
ContainerName string `json:"container_name"`
}
@@ -52,16 +56,22 @@ func HandleWebSSHTicket(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Access denied to this container"})
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"})
return
}
if isSubUserRequest(r) && c.PolicyBlocked {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: policyBlockedMessage(c)})
return
}
ticket := randomHex(32)
webSSHTickets.Lock()
cleanupExpiredWebSSHTicketsLocked(time.Now())
webSSHTickets.items[ticket] = webSSHTicket{
ContainerName: req.ContainerName,
SubUser: isSubUserRequest(r),
ExpiresAt: time.Now().Add(60 * time.Second),
}
webSSHTickets.Unlock()
@@ -86,7 +96,8 @@ func HandleWebSSH(w http.ResponseWriter, r *http.Request) {
return
}
if !consumeWebSSHTicket(ticket, containerName) {
item, ok := consumeWebSSHTicket(ticket, containerName)
if !ok {
http.Error(w, "invalid or expired ticket", http.StatusUnauthorized)
return
}
@@ -96,21 +107,39 @@ func HandleWebSSH(w http.ResponseWriter, r *http.Request) {
http.Error(w, "container not found", http.StatusNotFound)
return
}
if item.SubUser && c.PolicyBlocked {
http.Error(w, "虚拟机被策略临时封禁", http.StatusForbidden)
return
}
if c.Status != "running" {
http.Error(w, "container is not running", http.StatusBadRequest)
return
}
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
config.SaveConfig()
}
}
if c.IP == "" {
if c.IP == "" && !c.IsKVM() {
if ip, err := lxcManager.EnsureContainerIPv4(c.ID); err == nil && 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 == "" {
http.Error(w, "container ip is not available", http.StatusBadRequest)
return
@@ -127,6 +156,10 @@ func HandleWebSSH(w http.ResponseWriter, r *http.Request) {
defer ws.Close()
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")
if err := lxcManager.EnsureSSH(c.ID); err != nil {
writeWebSocketText(ws, nil, fmt.Sprintf("\r\nSSH auto setup failed: %v\r\n", err))
@@ -154,25 +187,48 @@ func HandleWebSSH(w http.ResponseWriter, r *http.Request) {
writeWebSocketText(ws, nil, fmt.Sprintf("Connecting to %s...\r\n", addr))
client, err := ssh.Dial("tcp", addr, sshConfig)
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 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
if c.IsKVM() {
writeWebSocketText(ws, nil, "\r\nSSH is not ready yet, preparing KVM guest service. This can take a few minutes on first boot...\r\n")
if setupErr := kvmManager.EnsureSSH(c.ID); setupErr != nil {
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 ip, ipErr := kvmManager.GetContainerIP(c.VirshName()); 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
}
} 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()
@@ -318,17 +374,17 @@ func writeWebSocketText(ws *websocket.Conn, writeMu *sync.Mutex, msg string) {
_ = ws.WriteMessage(websocket.TextMessage, []byte(msg))
}
func consumeWebSSHTicket(ticket, containerName string) bool {
func consumeWebSSHTicket(ticket, containerName string) (webSSHTicket, bool) {
now := time.Now()
webSSHTickets.Lock()
defer webSSHTickets.Unlock()
cleanupExpiredWebSSHTicketsLocked(now)
item, ok := webSSHTickets.items[ticket]
if !ok {
return false
return webSSHTicket{}, false
}
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) {
+580
View File
@@ -0,0 +1,580 @@
package api
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"math/big"
"net"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"clicd/internal/config"
)
type sslSettingsRequest struct {
Enabled bool `json:"enabled"`
Mode string `json:"mode"`
Target string `json:"target"`
Email string `json:"email"`
CertPEM string `json:"cert_pem"`
KeyPEM string `json:"key_pem"`
ApplyNow bool `json:"apply_now"`
}
type sslCertificateInfo struct {
Subject string `json:"subject"`
Issuer string `json:"issuer"`
DNSNames []string `json:"dns_names"`
IPNames []string `json:"ip_names"`
NotBefore string `json:"not_before"`
NotAfter string `json:"not_after"`
Valid bool `json:"valid"`
}
type sslSavedCertificateStatus struct {
config.SSLConfig
Certificate *sslCertificateInfo `json:"certificate,omitempty"`
}
type sslSettingsResponse struct {
config.SSLConfig
DetectedHost string `json:"detected_host"`
Certificate *sslCertificateInfo `json:"certificate,omitempty"`
ModeCertificates map[string]sslSavedCertificateStatus `json:"mode_certificates"`
NeedsRestart bool `json:"needs_restart,omitempty"`
}
func HandleSSLSettings(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: sslSettingsStatus(r, false)})
case http.MethodPut:
updateSSLSettings(w, r)
default:
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
}
}
func updateSSLSettings(w http.ResponseWriter, r *http.Request) {
var req sslSettingsRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
return
}
mode := config.NormalizeSSLMode(req.Mode)
if !req.Enabled || mode == config.SSLModeDisabled {
saveCurrentSSLSlot()
config.AppConfig.SSL = config.SSLConfig{Enabled: false, Mode: config.SSLModeDisabled}
if err := config.SaveConfig(); err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Save SSL settings failed"})
return
}
restartIfRequested(req.ApplyNow)
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "SSL disabled", Data: sslSettingsStatus(r, true)})
return
}
target := strings.TrimSpace(req.Target)
if target == "" {
target = detectedRequestHost(r)
}
if target == "" {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "SSL target is required"})
return
}
next, err := resolveSSLModeCertificate(mode, target, strings.TrimSpace(req.Email), req.CertPEM, req.KeyPEM)
if err != nil {
_ = config.SaveConfig()
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error(), Data: sslSettingsStatus(r, false)})
return
}
if err := validateCertificatePair(next.CertPath, next.KeyPath); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
return
}
next.LastIssuedAt = time.Now().Format(time.RFC3339)
next.Enabled = true
config.AppConfig.SSL = next
saveSSLSlot(next)
if err := config.SaveConfig(); err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Save SSL settings failed"})
return
}
restartIfRequested(req.ApplyNow)
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "SSL settings saved", Data: sslSettingsStatus(r, true)})
}
func sslSettingsStatus(r *http.Request, needsRestart bool) sslSettingsResponse {
cfg := config.AppConfig.SSL
cfg.KeyPath = maskExistingPath(cfg.KeyPath)
resp := sslSettingsResponse{
SSLConfig: cfg,
DetectedHost: detectedRequestHost(r),
ModeCertificates: sslModeCertificatesStatus(),
NeedsRestart: needsRestart,
}
if cert, err := readCertificateInfo(config.AppConfig.SSL.CertPath); err == nil {
resp.Certificate = cert
}
return resp
}
func resolveSSLModeCertificate(mode, target, email, certPEM, keyPEM string) (config.SSLConfig, error) {
if config.AppConfig.SSLCertificates == nil {
config.AppConfig.SSLCertificates = map[string]config.SSLConfig{}
}
next := config.AppConfig.SSLCertificates[mode]
next.Mode = mode
next.Target = target
if email != "" || next.Email == "" {
next.Email = email
}
var err error
switch mode {
case config.SSLModeUploaded:
if strings.TrimSpace(certPEM) != "" || strings.TrimSpace(keyPEM) != "" {
next.CertPath, next.KeyPath, err = saveUploadedCertificate(certPEM, keyPEM)
} else if next.CertPath == "" || next.KeyPath == "" {
err = fmt.Errorf("certificate and private key are required")
} else if !certificateUsable(next.CertPath, next.KeyPath, target) {
err = fmt.Errorf("uploaded certificate is expired, invalid, or does not match the target")
}
case config.SSLModeSelfSigned:
if !certificateUsable(next.CertPath, next.KeyPath, target) {
next.CertPath, next.KeyPath, err = generateSelfSignedCertificate(target)
}
case config.SSLModeLetsEncrypt:
if !certificateUsable(next.CertPath, next.KeyPath, target) {
next.CertPath, next.KeyPath, err = requestLetsEncryptCertificate(target, next.Email)
}
default:
err = fmt.Errorf("unsupported SSL mode")
}
if err != nil {
next.LastError = err.Error()
saveSSLSlot(next)
return next, err
}
next.LastError = ""
return next, nil
}
func sslModeCertificatesStatus() map[string]sslSavedCertificateStatus {
result := map[string]sslSavedCertificateStatus{}
for _, mode := range []string{config.SSLModeLetsEncrypt, config.SSLModeSelfSigned, config.SSLModeUploaded} {
cfg := config.AppConfig.SSLCertificates[mode]
cfg.KeyPath = maskExistingPath(cfg.KeyPath)
status := sslSavedCertificateStatus{SSLConfig: cfg}
if cert, err := readCertificateInfo(config.AppConfig.SSLCertificates[mode].CertPath); err == nil {
status.Certificate = cert
}
result[mode] = status
}
return result
}
func saveCurrentSSLSlot() {
if config.AppConfig.SSL.Mode == config.SSLModeDisabled || config.AppConfig.SSL.CertPath == "" {
return
}
saveSSLSlot(config.AppConfig.SSL)
}
func saveSSLSlot(ssl config.SSLConfig) {
mode := config.NormalizeSSLMode(ssl.Mode)
if mode == config.SSLModeDisabled {
return
}
if config.AppConfig.SSLCertificates == nil {
config.AppConfig.SSLCertificates = map[string]config.SSLConfig{}
}
ssl.Mode = mode
ssl.Enabled = false
config.AppConfig.SSLCertificates[mode] = ssl
}
func saveUploadedCertificate(certPEM, keyPEM string) (string, string, error) {
certPEM = strings.TrimSpace(certPEM)
keyPEM = strings.TrimSpace(keyPEM)
if certPEM == "" || keyPEM == "" {
return "", "", fmt.Errorf("certificate and private key are required")
}
if _, err := tls.X509KeyPair([]byte(certPEM), []byte(keyPEM)); err != nil {
return "", "", fmt.Errorf("certificate/private key mismatch: %v", err)
}
dir := sslStorageDir()
if err := os.MkdirAll(dir, 0700); err != nil {
return "", "", err
}
certPath := filepath.Join(dir, "uploaded-fullchain.pem")
keyPath := filepath.Join(dir, "uploaded-privkey.pem")
if err := os.WriteFile(certPath, []byte(certPEM+"\n"), 0600); err != nil {
return "", "", err
}
if err := os.WriteFile(keyPath, []byte(keyPEM+"\n"), 0600); err != nil {
return "", "", err
}
return certPath, keyPath, nil
}
func generateSelfSignedCertificate(target string) (string, string, error) {
target = strings.TrimSpace(target)
if target == "" {
return "", "", fmt.Errorf("self-signed certificate target is required")
}
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return "", "", err
}
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
if err != nil {
return "", "", err
}
now := time.Now()
tpl := x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{
CommonName: target,
},
NotBefore: now.Add(-time.Hour),
NotAfter: now.AddDate(1, 0, 0),
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
}
if ip := net.ParseIP(target); ip != nil {
tpl.IPAddresses = []net.IP{ip}
} else {
tpl.DNSNames = []string{target}
}
der, err := x509.CreateCertificate(rand.Reader, &tpl, &tpl, &key.PublicKey, key)
if err != nil {
return "", "", err
}
keyDER, err := x509.MarshalECPrivateKey(key)
if err != nil {
return "", "", err
}
dir := sslStorageDir()
if err := os.MkdirAll(dir, 0700); err != nil {
return "", "", err
}
certPath := filepath.Join(dir, "self-signed-fullchain.pem")
keyPath := filepath.Join(dir, "self-signed-privkey.pem")
certOut := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
keyOut := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
if err := os.WriteFile(certPath, certOut, 0600); err != nil {
return "", "", err
}
if err := os.WriteFile(keyPath, keyOut, 0600); err != nil {
return "", "", err
}
return certPath, keyPath, nil
}
func requestLetsEncryptCertificate(target, email string) (string, string, error) {
if _, err := exec.LookPath("certbot"); err != nil {
return "", "", fmt.Errorf("certbot is not installed on this server")
}
target = strings.TrimSpace(target)
if target == "" {
return "", "", fmt.Errorf("Let's Encrypt target is required")
}
args := []string{"certonly", "--non-interactive", "--agree-tos", "--standalone"}
if email != "" {
args = append(args, "--email", email)
} else {
args = append(args, "--register-unsafely-without-email")
}
if net.ParseIP(target) != nil {
if err := ensureCertbotSupportsIPCertificates(); err != nil {
return "", "", err
}
args = append(args, "--preferred-profile", "shortlived", "--ip-address", target)
} else {
args = append(args, "-d", target)
}
cmd := exec.Command("certbot", args...)
output, err := cmd.CombinedOutput()
if err != nil {
return "", "", fmt.Errorf("Let's Encrypt request failed: %s", strings.TrimSpace(string(output)))
}
certPath := filepath.Join("/etc/letsencrypt/live", target, "fullchain.pem")
keyPath := filepath.Join("/etc/letsencrypt/live", target, "privkey.pem")
if _, err := os.Stat(certPath); err != nil {
return "", "", fmt.Errorf("Let's Encrypt certificate file not found after issuance: %s", certPath)
}
if _, err := os.Stat(keyPath); err != nil {
return "", "", fmt.Errorf("Let's Encrypt private key file not found after issuance: %s", keyPath)
}
return certPath, keyPath, nil
}
func ensureCertbotSupportsIPCertificates() error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "certbot", "--help", "all")
output, err := cmd.CombinedOutput()
if ctx.Err() == context.DeadlineExceeded {
return fmt.Errorf("certbot check timed out")
}
if err != nil {
return fmt.Errorf("certbot capability check failed: %s", strings.TrimSpace(string(output)))
}
help := string(output)
if !strings.Contains(help, "--ip-address") || !strings.Contains(help, "--preferred-profile") {
return fmt.Errorf("current certbot does not support IP certificates; install Certbot 5.4+ from snap or another current source")
}
return nil
}
func validateCertificatePair(certPath, keyPath string) error {
certPEM, err := os.ReadFile(certPath)
if err != nil {
return err
}
keyPEM, err := os.ReadFile(keyPath)
if err != nil {
return err
}
if _, err := tls.X509KeyPair(certPEM, keyPEM); err != nil {
return fmt.Errorf("certificate/private key mismatch: %v", err)
}
return nil
}
func certificateUsable(certPath, keyPath, target string) bool {
if certPath == "" || keyPath == "" {
return false
}
if err := validateCertificatePair(certPath, keyPath); err != nil {
return false
}
cert, err := readLeafCertificate(certPath)
if err != nil {
return false
}
now := time.Now()
if now.Before(cert.NotBefore) || !now.Before(cert.NotAfter) {
return false
}
return certificateMatchesTarget(cert, target)
}
func certificateNeedsRenewal(certPath, keyPath, target string, renewBefore time.Duration) bool {
if certPath == "" || keyPath == "" {
return true
}
if err := validateCertificatePair(certPath, keyPath); err != nil {
return true
}
cert, err := readLeafCertificate(certPath)
if err != nil {
return true
}
now := time.Now()
if now.Before(cert.NotBefore) || !now.Before(cert.NotAfter) {
return true
}
if !certificateMatchesTarget(cert, target) {
return true
}
return cert.NotAfter.Sub(now) <= renewBefore
}
func certificateMatchesTarget(cert *x509.Certificate, target string) bool {
target = strings.TrimSpace(strings.Trim(target, "[]"))
if target == "" {
return true
}
if ip := net.ParseIP(target); ip != nil {
for _, certIP := range cert.IPAddresses {
if certIP.Equal(ip) {
return true
}
}
return false
}
if err := cert.VerifyHostname(target); err != nil {
return false
}
return true
}
func readCertificateInfo(certPath string) (*sslCertificateInfo, error) {
cert, err := readLeafCertificate(certPath)
if err != nil {
return nil, err
}
ipNames := make([]string, 0, len(cert.IPAddresses))
for _, ip := range cert.IPAddresses {
ipNames = append(ipNames, ip.String())
}
return &sslCertificateInfo{
Subject: cert.Subject.String(),
Issuer: cert.Issuer.String(),
DNSNames: cert.DNSNames,
IPNames: ipNames,
NotBefore: cert.NotBefore.Format(time.RFC3339),
NotAfter: cert.NotAfter.Format(time.RFC3339),
Valid: time.Now().After(cert.NotBefore) && time.Now().Before(cert.NotAfter),
}, nil
}
func readLeafCertificate(certPath string) (*x509.Certificate, error) {
if certPath == "" {
return nil, errors.New("certificate path is empty")
}
data, err := os.ReadFile(certPath)
if err != nil {
return nil, err
}
block, _ := pem.Decode(data)
if block == nil {
return nil, errors.New("certificate PEM is invalid")
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return nil, err
}
return cert, nil
}
func detectedRequestHost(r *http.Request) string {
host := strings.TrimSpace(r.Host)
if host == "" {
return firstPublicInterfaceIP()
}
if h, _, err := net.SplitHostPort(host); err == nil {
host = h
}
host = strings.Trim(host, "[]")
if host == "localhost" || net.ParseIP(host).IsLoopback() {
if ip := firstPublicInterfaceIP(); ip != "" {
return ip
}
}
return host
}
func firstPublicInterfaceIP() string {
addrs, err := net.InterfaceAddrs()
if err != nil {
return ""
}
for _, addr := range addrs {
ipNet, ok := addr.(*net.IPNet)
if !ok {
continue
}
ip := ipNet.IP.To4()
if ip == nil || ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() {
continue
}
return ip.String()
}
return ""
}
func sslStorageDir() string {
dataDir := config.AppConfig.DataDir
if dataDir == "" {
dataDir = "/root/.clicd"
}
return filepath.Join(dataDir, "ssl")
}
func maskExistingPath(path string) string {
if path == "" {
return ""
}
return path
}
func restartIfRequested(applyNow bool) {
if !applyNow {
return
}
go func() {
time.Sleep(500 * time.Millisecond)
_ = exec.Command("systemctl", "restart", "clicd").Start()
}()
}
func StartSSLRenewalMonitor() {
go func() {
time.Sleep(30 * time.Second)
renewSavedSSLCertificates()
ticker := time.NewTicker(6 * time.Hour)
defer ticker.Stop()
for range ticker.C {
renewSavedSSLCertificates()
}
}()
}
func renewSavedSSLCertificates() {
if config.AppConfig == nil || len(config.AppConfig.SSLCertificates) == 0 {
return
}
changed := false
for mode, cert := range config.AppConfig.SSLCertificates {
mode = config.NormalizeSSLMode(mode)
if cert.Target == "" || mode == config.SSLModeDisabled || mode == config.SSLModeUploaded {
continue
}
var certPath, keyPath string
var err error
switch mode {
case config.SSLModeLetsEncrypt:
if !certificateNeedsRenewal(cert.CertPath, cert.KeyPath, cert.Target, 48*time.Hour) {
continue
}
certPath, keyPath, err = requestLetsEncryptCertificate(cert.Target, cert.Email)
case config.SSLModeSelfSigned:
if !certificateNeedsRenewal(cert.CertPath, cert.KeyPath, cert.Target, 30*24*time.Hour) {
continue
}
certPath, keyPath, err = generateSelfSignedCertificate(cert.Target)
}
if err != nil {
cert.LastError = err.Error()
config.AppConfig.SSLCertificates[mode] = cert
changed = true
continue
}
cert.CertPath = certPath
cert.KeyPath = keyPath
cert.LastIssuedAt = time.Now().Format(time.RFC3339)
cert.LastError = ""
config.AppConfig.SSLCertificates[mode] = cert
if config.AppConfig.SSL.Enabled && config.AppConfig.SSL.Mode == mode {
active := cert
active.Enabled = true
config.AppConfig.SSL = active
}
changed = true
}
if changed {
_ = config.SaveConfig()
}
}
+310 -37
View File
@@ -49,6 +49,9 @@ func HandleSubUserCreate(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
if !requireScope(w, r, "subuser:create") {
return
}
var req struct {
ContainerName string `json:"container_name"`
@@ -66,7 +69,7 @@ func HandleSubUserCreate(w http.ResponseWriter, r *http.Request) {
}
containerName := c.Name
// Check if sub-user already exists for this container
// Check if sub-user already exists and return the same management password.
for i := range config.AppConfig.SubUsers {
su := &config.AppConfig.SubUsers[i]
for _, uuid := range su.ContainerUUIDs {
@@ -74,18 +77,27 @@ func HandleSubUserCreate(w http.ResponseWriter, r *http.Request) {
if su.AccessCode == "" {
su.AccessCode = generateRandomStr(8)
}
password := generateRandomStr(16)
if hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost); err == nil {
password := su.Password
message := "Sub-user link returned"
if password == "" {
password = generateRandomStr(16)
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to generate password"})
return
}
su.PassHash = string(hash)
su.Password = password
su.Token = ""
su.TokenVersion++
message = "Sub-user password generated"
}
su.Password = ""
su.Token = ""
su.ContainerNames = appendUniqueString(su.ContainerNames, containerName)
su.ContainerUUIDs = appendUniqueString(su.ContainerUUIDs, c.UUID)
config.SaveConfig()
jsonResponse(w, http.StatusOK, APIResponse{
Success: true,
Message: "Sub-user password rotated",
Message: message,
Data: newSubUserResponse(*su, password),
})
return
@@ -104,6 +116,7 @@ func HandleSubUserCreate(w http.ResponseWriter, r *http.Request) {
subUser := config.SubUser{
ID: "sub-" + generateRandomStr(8),
Username: username,
Password: password,
PassHash: string(hash),
ContainerNames: []string{containerName},
ContainerUUIDs: []string{c.UUID},
@@ -134,17 +147,24 @@ func HandleSubUserLogin(w http.ResponseWriter, r *http.Request) {
return
}
clientIP := r.Header.Get("X-Forwarded-For")
if clientIP == "" {
clientIP = r.RemoteAddr
}
clientUA := r.Header.Get("User-Agent")
// Find sub-user
for _, su := range config.AppConfig.SubUsers {
if su.Username == req.Username {
if err := bcrypt.CompareHashAndPassword([]byte(su.PassHash), []byte(req.Password)); err == nil {
// Generate fresh token
containerUUIDs := activeSubUserContainerUUIDs(&su)
if len(containerUUIDs) == 0 {
config.AddLoginLog(su.Username, clientIP, clientUA, false)
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "No active container is assigned to this user"})
return
}
tokenStr := newSubUserToken(su.Username, containerUUIDs, time.Now().Add(24*time.Hour))
tokenStr := newSubUserToken(su.Username, containerUUIDs, time.Now().Add(24*time.Hour), su.TokenVersion)
config.AddLoginLog(su.Username, clientIP, clientUA, true)
jsonResponse(w, http.StatusOK, APIResponse{
Success: true,
@@ -155,6 +175,8 @@ func HandleSubUserLogin(w http.ResponseWriter, r *http.Request) {
},
})
return
} else {
config.AddLoginLog(su.Username, clientIP, clientUA, false)
}
}
}
@@ -179,19 +201,28 @@ func HandleSubUserAccessCode(w http.ResponseWriter, r *http.Request) {
}
// Find sub-user by access code
clientIP := r.Header.Get("X-Forwarded-For")
if clientIP == "" {
clientIP = r.RemoteAddr
}
clientUA := r.Header.Get("User-Agent")
for _, su := range config.AppConfig.SubUsers {
if su.AccessCode == req.Code {
if err := bcrypt.CompareHashAndPassword([]byte(su.PassHash), []byte(req.Password)); err != nil {
config.AddLoginLog(su.Username, clientIP, clientUA, false)
jsonResponse(w, http.StatusUnauthorized, APIResponse{Success: false, Message: "Invalid password"})
return
}
containerUUIDs := activeSubUserContainerUUIDs(&su)
if len(containerUUIDs) == 0 {
config.AddLoginLog(su.Username, clientIP, clientUA, false)
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "No active container is assigned to this link"})
return
}
tokenStr := newSubUserToken(su.Username, containerUUIDs, time.Now().Add(24*time.Hour))
tokenStr := newSubUserToken(su.Username, containerUUIDs, time.Now().Add(24*time.Hour), su.TokenVersion)
config.AddLoginLog(su.Username, clientIP, clientUA, true)
jsonResponse(w, http.StatusOK, APIResponse{
Success: true,
@@ -208,10 +239,11 @@ func HandleSubUserAccessCode(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusUnauthorized, APIResponse{Success: false, Message: "Invalid access code"})
}
func newSubUserToken(username string, containerUUIDs []string, expiresAt time.Time) string {
func newSubUserToken(username string, containerUUIDs []string, expiresAt time.Time, tokenVersion int) string {
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"sub_user": username,
"container_uuids": containerUUIDs,
"token_version": tokenVersion,
"exp": expiresAt.Unix(),
"iat": time.Now().Unix(),
})
@@ -252,13 +284,40 @@ func subUserAllowedContainers(r *http.Request) (subUserAccess, bool) {
return allowed, true
}
func requestAllowedContainers(r *http.Request) (subUserAccess, bool) {
if ctx, ok := authContextFromRequest(r); ok {
if ctx.Type == authTypeAPIKey && len(ctx.ContainerUUIDs) == 0 {
return subUserAccess{}, false
}
if ctx.Type == authTypeSubUser || ctx.Type == authTypeAPIKey {
allowed := subUserAccess{names: make(map[string]bool), uuids: make(map[string]bool)}
for _, uuid := range ctx.ContainerUUIDs {
allowed.uuids[uuid] = true
}
if ctx.Type == authTypeSubUser && len(ctx.ContainerUUIDs) == 0 {
legacy, ok := subUserAllowedContainers(r)
if ok {
return legacy, true
}
}
return allowed, true
}
}
return subUserAllowedContainers(r)
}
func isAccessRestrictedRequest(r *http.Request) bool {
_, restricted := requestAllowedContainers(r)
return restricted
}
func containerByIdentifier(identifier string) *config.Container {
return config.FindContainerByIdentifier(identifier)
}
func isContainerAllowedForRequest(r *http.Request, identifier string) bool {
allowed, isSubUser := subUserAllowedContainers(r)
if !isSubUser {
allowed, restricted := requestAllowedContainers(r)
if !restricted {
return true
}
c := containerByIdentifier(identifier)
@@ -274,6 +333,9 @@ func HandleAuditLogs(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
if !requireScope(w, r, "audit:read") {
return
}
logs := config.AppConfig.AuditLogs
if logs == nil {
@@ -298,12 +360,20 @@ func SubUserMiddleware(next http.HandlerFunc) http.HandlerFunc {
}
path := r.URL.Path
if path == "/api/tasks" && r.Method == http.MethodGet {
containerPrefix := "/api/containers/"
containerListPath := "/api/containers"
tasksPath := "/api/tasks"
if strings.HasPrefix(path, "/api/v1/") {
containerPrefix = "/api/v1/containers/"
containerListPath = "/api/v1/containers"
tasksPath = "/api/v1/tasks"
}
if path == tasksPath && r.Method == http.MethodGet {
next(w, r)
return
}
if path == "/api/containers" {
if path == containerListPath {
if r.Method != http.MethodGet {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Sub-users cannot create containers"})
return
@@ -312,8 +382,8 @@ func SubUserMiddleware(next http.HandlerFunc) http.HandlerFunc {
return
}
if len(path) > len("/api/containers/") {
rest := path[len("/api/containers/"):]
if strings.HasPrefix(path, containerPrefix) {
rest := path[len(containerPrefix):]
parts := splitPath(rest)
if len(parts) > 0 && parts[0] != "" {
c := containerByIdentifier(parts[0])
@@ -323,7 +393,11 @@ func SubUserMiddleware(next http.HandlerFunc) http.HandlerFunc {
}
action := ""
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) {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Action is not allowed for this link"})
@@ -340,8 +414,8 @@ func SubUserMiddleware(next http.HandlerFunc) http.HandlerFunc {
}
func filterContainersForRequest(r *http.Request, containers []config.Container) []config.Container {
allowed, isSubUser := subUserAllowedContainers(r)
if !isSubUser {
allowed, restricted := requestAllowedContainers(r)
if !restricted {
return containers
}
filtered := make([]config.Container, 0, len(containers))
@@ -354,33 +428,66 @@ func filterContainersForRequest(r *http.Request, containers []config.Container)
}
func filterTasksForRequest(r *http.Request, tasks []*Task) []*Task {
allowed, isSubUser := subUserAllowedContainers(r)
if !isSubUser {
return tasks
}
filtered := make([]*Task, 0, len(tasks))
for _, task := range tasks {
if c := config.FindContainer(task.ContainerID); c != nil && isContainerAllowed(allowed, c) {
if isTaskAllowedForRequest(r, task) {
filtered = append(filtered, task)
continue
}
if task.ContainerName != "" {
if c := config.FindContainerByName(task.ContainerName); c != nil && isContainerAllowed(allowed, c) {
filtered = append(filtered, task)
continue
}
}
if task.Config.Name != "" {
if c := config.FindContainerByName(task.Config.Name); c != nil && isContainerAllowed(allowed, c) {
filtered = append(filtered, task)
}
}
}
return filtered
}
func isTaskAllowedForRequest(r *http.Request, task *Task) bool {
allowed, restricted := requestAllowedContainers(r)
if !restricted {
return true
}
if task == nil {
return false
}
if c := config.FindContainer(task.ContainerID); c != nil && isContainerAllowed(allowed, c) {
return true
}
if task.ContainerName != "" {
if c := config.FindContainerByName(task.ContainerName); c != nil && isContainerAllowed(allowed, c) {
return true
}
}
if task.Config.Name != "" {
if c := config.FindContainerByName(task.Config.Name); c != nil && isContainerAllowed(allowed, c) {
return true
}
}
return false
}
func isContainerAllowed(allowed subUserAccess, c *config.Container) bool {
return c != nil && c.UUID != "" && allowed.uuids[c.UUID]
if c == nil {
return false
}
if c.UUID != "" && allowed.uuids[c.UUID] {
return true
}
return c.Name != "" && allowed.names[c.Name]
}
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 {
@@ -461,3 +568,169 @@ func splitBy(s, sep string) []string {
result = append(result, current)
return result
}
// SubUserListItem is the enriched sub-user info returned by the list API
type SubUserListItem struct {
ID string `json:"id"`
Username string `json:"username"`
ContainerNames []string `json:"container_names"`
ContainerUUIDs []string `json:"container_uuids"`
ContainerName string `json:"container_name"`
ContainerUUID string `json:"container_uuid"`
AccessCode string `json:"access_code"`
Password string `json:"password,omitempty"`
CreatedAt string `json:"created_at"`
LastLogin string `json:"last_login"`
LastLoginIP string `json:"last_login_ip"`
LastLoginUA string `json:"last_login_ua"`
}
// HandleSubUserList returns the list of all sub-users with container info
func HandleSubUserList(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
if !requireScope(w, r, "subuser:read") {
return
}
result := make([]SubUserListItem, 0, len(config.AppConfig.SubUsers))
for _, su := range config.AppConfig.SubUsers {
item := SubUserListItem{
ID: su.ID,
Username: su.Username,
ContainerNames: su.ContainerNames,
ContainerUUIDs: su.ContainerUUIDs,
AccessCode: su.AccessCode,
Password: su.Password,
CreatedAt: su.CreatedAt,
}
// Resolve container name from first active UUID
for _, uuid := range su.ContainerUUIDs {
if c := config.FindContainerByUUID(uuid); c != nil {
item.ContainerName = c.Name
item.ContainerUUID = c.UUID
break
}
}
if item.ContainerName == "" && len(su.ContainerNames) > 0 {
item.ContainerName = su.ContainerNames[0]
}
// Find last login time
for i := len(config.AppConfig.LoginLogs) - 1; i >= 0; i-- {
log := config.AppConfig.LoginLogs[i]
if log.Username == su.Username {
item.LastLogin = log.Time
item.LastLoginIP = log.IP
item.LastLoginUA = log.UserAgent
break
}
}
// Skip orphaned sub-users with no active containers
if item.ContainerName == "" && item.ContainerUUID == "" {
continue
}
result = append(result, item)
}
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: result})
}
// HandleSubUserAction handles actions on a specific sub-user
func HandleSubUserAction(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/api/v1/sub-users/")
path = strings.TrimPrefix(path, "/api/sub-users/")
parts := strings.SplitN(path, "/", 2)
subUserID := parts[0]
action := ""
if len(parts) > 1 {
action = parts[1]
}
// Find sub-user
var target *config.SubUser
for i := range config.AppConfig.SubUsers {
if config.AppConfig.SubUsers[i].ID == subUserID {
target = &config.AppConfig.SubUsers[i]
break
}
}
if target == nil {
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Sub-user not found"})
return
}
switch {
case action == "rotate-password" && r.Method == http.MethodPost:
if !requireScope(w, r, "subuser:update") {
return
}
password := generateRandomStr(16)
if hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost); err == nil {
target.PassHash = string(hash)
target.Password = password
target.Token = ""
target.TokenVersion++ // invalidate all existing tokens
config.SaveConfig()
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]string{
"password": password,
"access_code": target.AccessCode,
"username": target.Username,
}})
return
}
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to generate password"})
case action == "audit-logs" && r.Method == http.MethodGet:
if !requireScope(w, r, "audit:read") {
return
}
// Filter audit logs for this sub-user
logs := filterSubUserAuditLogs(target.Username)
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: logs})
case action == "login-logs" && r.Method == http.MethodGet:
if !requireScope(w, r, "loginlog:read") {
return
}
// Filter login logs for this sub-user
logs := filterSubUserLoginLogs(target.Username)
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: logs})
default:
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Action not found"})
}
}
func filterSubUserAuditLogs(username string) []config.AuditLog {
result := make([]config.AuditLog, 0)
for i := len(config.AppConfig.AuditLogs) - 1; i >= 0; i-- {
log := config.AppConfig.AuditLogs[i]
if log.User == username || strings.HasPrefix(log.User, "user:") && strings.Contains(log.User, username) {
result = append(result, log)
}
}
if result == nil {
result = []config.AuditLog{}
}
return result
}
func filterSubUserLoginLogs(username string) []config.SavedLoginLog {
result := make([]config.SavedLoginLog, 0)
for i := len(config.AppConfig.LoginLogs) - 1; i >= 0; i-- {
log := config.AppConfig.LoginLogs[i]
if log.Username == username {
result = append(result, log)
}
}
if result == nil {
result = []config.SavedLoginLog{}
}
return result
}
+50 -27
View File
@@ -11,19 +11,27 @@ import (
)
type SwapInfo struct {
TotalMB int64 `json:"total_mb"`
UsedMB int64 `json:"used_mb"`
FreeMB int64 `json:"free_mb"`
Enabled bool `json:"enabled"`
SwapFile string `json:"swap_file"`
TotalMB int64 `json:"total_mb"`
UsedMB int64 `json:"used_mb"`
FreeMB int64 `json:"free_mb"`
Enabled bool `json:"enabled"`
SwapFile string `json:"swap_file"`
}
const (
minSwapSizeMB = 128
maxSwapSizeMB = 262144
)
// HandleSwapInfo returns current swap status
func HandleSwapInfo(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
if !requireScope(w, r, "swap:read") {
return
}
info := getSwapInfo()
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: info})
@@ -35,9 +43,12 @@ func HandleSwapManage(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
if !requireScope(w, r, "swap:manage") {
return
}
var req struct {
Action string `json:"action"` // create, enable, disable, resize
Action string `json:"action"` // create, enable, disable, resize
SizeMB int `json:"size_mb"` // for create/resize
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@@ -46,54 +57,63 @@ func HandleSwapManage(w http.ResponseWriter, r *http.Request) {
}
var msg string
var err error
switch req.Action {
case "create":
if req.SizeMB <= 0 {
req.SizeMB = 2048
}
err := createSwap(req.SizeMB)
if err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
return
if err = validateSwapSize(req.SizeMB); err == nil {
err = createSwap(req.SizeMB)
}
msg = fmt.Sprintf("已创建 %d MB SWAP", req.SizeMB)
case "enable":
err := enableSwap()
if err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
return
}
err = enableSwap()
msg = "SWAP 已启用"
case "disable":
err := disableSwap()
if err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
return
}
err = disableSwap()
msg = "SWAP 已禁用"
case "resize":
if req.SizeMB <= 0 {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid size"})
return
if err = validateSwapSize(req.SizeMB); err == nil {
err = disableSwap()
}
if err == nil {
err = createSwap(req.SizeMB)
}
if err == nil {
err = enableSwap()
}
disableSwap()
createSwap(req.SizeMB)
enableSwap()
msg = fmt.Sprintf("SWAP 已调整为 %d MB", req.SizeMB)
default:
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid action: " + req.Action})
return
}
if err != nil {
auditRequest(r, "swap."+req.Action, "/swapfile", fmt.Sprintf("size_mb=%d", req.SizeMB), false, err.Error())
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
return
}
info := getSwapInfo()
auditRequest(r, "swap."+req.Action, "/swapfile", fmt.Sprintf("size_mb=%d", req.SizeMB), true, "")
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: msg, Data: info})
}
func validateSwapSize(sizeMB int) error {
if sizeMB < minSwapSizeMB {
return fmt.Errorf("swap size must be at least %d MB", minSwapSizeMB)
}
if sizeMB > maxSwapSizeMB {
return fmt.Errorf("swap size cannot exceed %d MB", maxSwapSizeMB)
}
return nil
}
func getSwapInfo() SwapInfo {
info := SwapInfo{SwapFile: "/swapfile"}
@@ -160,6 +180,9 @@ func createSwap(sizeMB int) error {
func enableSwap() error {
swapFile := "/swapfile"
if _, err := os.Stat(swapFile); os.IsNotExist(err) {
if getSwapInfo().Enabled {
return nil
}
return fmt.Errorf("swap 文件不存在,请先创建")
}
@@ -180,7 +203,7 @@ func disableSwap() error {
cmd := exec.Command("swapoff", swapFile)
output, err := cmd.CombinedOutput()
if err != nil {
if strings.Contains(string(output), "No such") {
if strings.Contains(string(output), "No such") || strings.Contains(string(output), "Invalid argument") {
return nil
}
return fmt.Errorf("禁用 swap 失败: %v, %s", err, string(output))
+135 -28
View File
@@ -35,6 +35,8 @@ type Task struct {
Config lxc.ContainerConfig `json:"config,omitempty"`
Name string `json:"name,omitempty"`
User string `json:"user,omitempty"` // who created this task
IP string `json:"ip,omitempty"`
UserAgent string `json:"user_agent,omitempty"`
}
type TaskQueue struct {
@@ -100,6 +102,10 @@ func (q *TaskQueue) EnqueueBatch(taskType TaskType, ids []int, templateID string
}
func (q *TaskQueue) EnqueueBatchWithUser(taskType TaskType, ids []int, templateID string, user string) []string {
return q.EnqueueBatchWithAudit(taskType, ids, templateID, user, "", "")
}
func (q *TaskQueue) EnqueueBatchWithAudit(taskType TaskType, ids []int, templateID string, user string, ip string, userAgent string) []string {
q.mu.Lock()
defer q.mu.Unlock()
var result []string
@@ -109,16 +115,20 @@ func (q *TaskQueue) EnqueueBatchWithUser(taskType TaskType, ids []int, templateI
if c != nil {
name = c.Name
}
result = append(result, q.enqueueSingleWithUser(id, name, taskType, templateID, user))
result = append(result, q.enqueueSingleWithAudit(id, name, taskType, templateID, user, ip, userAgent))
}
q.persistTasks()
return result
}
func (q *TaskQueue) EnqueueBatchCreate(configs []lxc.ContainerConfig) []string {
return q.EnqueueBatchCreateWithAudit(configs, "admin", "", "")
}
func (q *TaskQueue) EnqueueBatchCreateWithAudit(configs []lxc.ContainerConfig, user string, ip string, userAgent string) []string {
q.mu.Lock()
defer q.mu.Unlock()
return q.enqueueBatchCreateList(configs)
return q.enqueueBatchCreateList(configs, user, ip, userAgent)
}
func (q *TaskQueue) ActiveCreateNames() map[string]bool {
@@ -141,7 +151,7 @@ func (q *TaskQueue) ActiveCreateNames() map[string]bool {
return names
}
func (q *TaskQueue) enqueueBatchCreateList(configs []lxc.ContainerConfig) []string {
func (q *TaskQueue) enqueueBatchCreateList(configs []lxc.ContainerConfig, user string, ip string, userAgent string) []string {
var result []string
for _, cfg := range configs {
cfgCopy := cfg
@@ -155,6 +165,9 @@ func (q *TaskQueue) enqueueBatchCreateList(configs []lxc.ContainerConfig) []stri
Status: "pending",
CreatedAt: time.Now().Format("2006-01-02 15:04:05"),
Config: cfgCopy,
User: user,
IP: ip,
UserAgent: userAgent,
}
q.enqueueTask(task)
result = append(result, task.ID)
@@ -168,6 +181,10 @@ func (q *TaskQueue) enqueueSingle(containerID int, containerName string, taskTyp
}
func (q *TaskQueue) enqueueSingleWithUser(containerID int, containerName string, taskType TaskType, templateID string, user string) string {
return q.enqueueSingleWithAudit(containerID, containerName, taskType, templateID, user, "", "")
}
func (q *TaskQueue) enqueueSingleWithAudit(containerID int, containerName string, taskType TaskType, templateID string, user string, ip string, userAgent string) string {
id := q.nextID
q.nextID++
task := &Task{
@@ -179,11 +196,31 @@ func (q *TaskQueue) enqueueSingleWithUser(containerID int, containerName string,
CreatedAt: time.Now().Format("2006-01-02 15:04:05"),
TemplateID: templateID,
User: user,
IP: ip,
UserAgent: userAgent,
}
q.enqueueTask(task)
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.
// If a restored task already has a same-name container in config, it resumes
// initialization instead of creating another ct-{id}.
@@ -214,7 +251,7 @@ func (q *TaskQueue) createWorker() {
c := config.FindContainerByName(task.Config.Name)
if c == nil {
// 1) Download image + apply limits (lxc-create)
err := lxcManager.CreateContainer(task.Config)
err := createByRuntime(task.Config)
if err != nil {
task.Status = "failed"
task.Error = err.Error()
@@ -244,10 +281,10 @@ func (q *TaskQueue) createWorker() {
// 3) Start + initialize SSH/network in the same worker.
// If init fails, destroy the container so no dead entry remains.
startErr := lxcManager.StartContainer(c.ID)
startErr := startByRuntime(c.ID)
if startErr != nil {
if createdByTask {
lxcManager.DestroyContainer(c.ID)
_ = destroyByRuntime(c.ID)
}
task.Status = "failed"
task.Error = startErr.Error()
@@ -292,13 +329,13 @@ func (q *TaskQueue) opWorker() {
if err == nil {
switch task.Type {
case TaskStart:
err = lxcManager.StartContainer(task.ContainerID)
err = startByRuntime(task.ContainerID)
case TaskStop:
err = lxcManager.StopContainer(task.ContainerID)
err = stopByRuntime(task.ContainerID)
case TaskRestart:
err = lxcManager.RestartContainer(task.ContainerID)
err = restartByRuntime(task.ContainerID)
case TaskDelete:
err = lxcManager.DestroyContainer(task.ContainerID)
err = destroyByRuntime(task.ContainerID)
if err == nil {
time.Sleep(1 * time.Second)
if config.FindContainer(task.ContainerID) != nil {
@@ -306,7 +343,7 @@ func (q *TaskQueue) opWorker() {
}
}
case TaskReinstall:
err = lxcManager.ReinstallContainer(task.ContainerID, task.TemplateID)
err = reinstallByRuntime(task.ContainerID, task.TemplateID)
}
}
@@ -318,17 +355,21 @@ func (q *TaskQueue) opWorker() {
if err != nil {
task.Status = "failed"
task.Error = err.Error()
config.AddAuditLog(string(task.Type), task.ContainerName, "失败: "+err.Error(), auditUser)
config.AddAuditLogFull(string(task.Type), task.ContainerName, "失败: "+err.Error(), auditUser, task.IP, task.UserAgent, false, err.Error())
} else {
task.Status = "done"
config.AddAuditLog(string(task.Type), task.ContainerName, "成功", auditUser)
config.AddAuditLogFull(string(task.Type), task.ContainerName, "成功", auditUser, task.IP, task.UserAgent, true, "")
switch task.Type {
case TaskStart:
config.UpdateContainerStatus(task.ContainerID, "running")
clearPolicyBlockAfterAdminRecovery(task)
case TaskStop:
config.UpdateContainerStatus(task.ContainerID, "stopped")
case TaskRestart:
config.UpdateContainerStatus(task.ContainerID, "running")
clearPolicyBlockAfterAdminRecovery(task)
case TaskReinstall:
clearPolicyBlockAfterAdminRecovery(task)
}
}
q.persistTasks()
@@ -336,6 +377,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 {
if task.Type == TaskCreate {
return nil
@@ -379,6 +431,8 @@ func (q *TaskQueue) persistTasks() {
TemplateID: t.TemplateID,
Config: string(cfgJSON),
User: t.User,
IP: t.IP,
UserAgent: t.UserAgent,
})
}
config.SaveTasks(saved)
@@ -411,13 +465,10 @@ func HandleSingleTaskAction(w http.ResponseWriter, r *http.Request, id int, acti
name = c.Name
}
// Determine user from JWT claims
user := "admin"
if claims, ok := claimsFromRequest(r); ok {
if subUser, _ := claims["sub_user"].(string); subUser != "" {
user = "user:" + subUser
}
}
// Determine user from authenticated request context.
user := requestActor(r)
ip := clientIP(r)
userAgent := r.Header.Get("User-Agent")
var taskType TaskType
var templateID string
@@ -442,7 +493,11 @@ func HandleSingleTaskAction(w http.ResponseWriter, r *http.Request, id int, acti
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"})
return
}
@@ -452,7 +507,7 @@ func HandleSingleTaskAction(w http.ResponseWriter, r *http.Request, id int, acti
return
}
ids := globalQueue.EnqueueBatchWithUser(taskType, []int{id}, templateID, user)
ids := globalQueue.EnqueueBatchWithAudit(taskType, []int{id}, templateID, user, ip, userAgent)
jsonResponse(w, http.StatusAccepted, APIResponse{
Success: true,
Message: "Task queued",
@@ -466,6 +521,13 @@ func HandleBatchCreate(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
if !requireScope(w, r, "container:create") {
return
}
if isAccessRestrictedRequest(r) {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Container-bound API keys cannot create containers"})
return
}
var req struct {
Containers []lxc.ContainerConfig `json:"containers"`
}
@@ -502,13 +564,14 @@ func HandleBatchCreate(w http.ResponseWriter, r *http.Request) {
if req.Containers[i].VCPU <= 0 {
req.Containers[i].VCPU = 1
}
req.Containers[i].Virtualization = runtimeFromRequest(req.Containers[i].Virtualization)
if req.Containers[i].RAMMB < 128 {
req.Containers[i].RAMMB = 512
}
if req.Containers[i].DiskGB < 1 {
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"})
return
}
@@ -518,13 +581,13 @@ func HandleBatchCreate(w http.ResponseWriter, r *http.Request) {
if req.Containers[i].SnapshotLimit <= 0 {
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()})
return
}
requestNames[name] = true
}
ids := globalQueue.EnqueueBatchCreate(req.Containers)
ids := globalQueue.EnqueueBatchCreateWithAudit(req.Containers, requestActor(r), clientIP(r), r.UserAgent())
jsonResponse(w, http.StatusAccepted, APIResponse{Success: true, Data: ids})
}
@@ -534,6 +597,10 @@ func HandleBatchAction(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
if !hasAnyScope(r, "container:power", "container:delete", "container:reinstall") {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Insufficient API key scope"})
return
}
var req struct {
Action string `json:"action"`
Containers []int `json:"containers"`
@@ -545,21 +612,47 @@ func HandleBatchAction(w http.ResponseWriter, r *http.Request) {
}
var taskType TaskType
var requiredScope string
switch req.Action {
case "start":
taskType = TaskStart
requiredScope = "container:power"
case "stop":
taskType = TaskStop
requiredScope = "container:power"
case "restart":
taskType = TaskRestart
requiredScope = "container:power"
case "delete":
taskType = TaskDelete
requiredScope = "container:delete"
case "reinstall":
if req.TemplateID == "" {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "template_id required"})
return
}
if !isTemplateEnabledAndDownloaded(req.TemplateID) {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Template is not enabled or downloaded"})
return
}
taskType = TaskReinstall
requiredScope = "container:reinstall"
default:
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Unknown action"})
return
}
if !requireScope(w, r, requiredScope) {
return
}
for _, id := range req.Containers {
c := config.FindContainer(id)
if c == nil || !isContainerAllowedForRequest(r, c.UUID) {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Access denied to one or more containers"})
return
}
}
ids := globalQueue.EnqueueBatch(taskType, req.Containers, req.TemplateID)
ids := globalQueue.EnqueueBatchWithAudit(taskType, req.Containers, req.TemplateID, requestActor(r), clientIP(r), r.UserAgent())
jsonResponse(w, http.StatusAccepted, APIResponse{Success: true, Data: ids})
}
@@ -569,13 +662,22 @@ func HandleTaskDelete(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
// URL: /api/tasks/{id}
taskID := strings.TrimPrefix(r.URL.Path, "/api/tasks/")
if !requireScope(w, r, "task:delete") {
return
}
// URL: /api/tasks/{id} or /api/v1/tasks/{id}
taskID := strings.TrimPrefix(r.URL.Path, "/api/v1/tasks/")
taskID = strings.TrimPrefix(taskID, "/api/tasks/")
if taskID == "" {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Task ID required"})
return
}
globalQueue.mu.Lock()
if task := globalQueue.tasks[taskID]; task != nil && !isTaskAllowedForRequest(r, task) {
globalQueue.mu.Unlock()
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Access denied to this task"})
return
}
delete(globalQueue.tasks, taskID)
// Also remove from both queues if pending
newCreate := make([]*Task, 0, len(globalQueue.createQueue))
@@ -603,6 +705,9 @@ func HandleTasks(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
if !requireScope(w, r, "task:read") {
return
}
tasks := globalQueue.GetTasks()
tasks = filterTasksForRequest(r, tasks)
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: tasks})
@@ -639,6 +744,8 @@ func RestoreTasks() {
TemplateID: st.TemplateID,
Config: cfg,
User: st.User,
IP: st.IP,
UserAgent: st.UserAgent,
}
if st.Status == "pending" || st.Status == "running" {
// Reset running tasks back to pending so they get retried
+275
View File
@@ -0,0 +1,275 @@
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
}
if !requireScope(w, r, "terminal:vnc") {
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) {
if ctx, ok := authContextFromRequest(r); ok {
switch ctx.Type {
case authTypeSubUser:
return ctx.Username, true
case authTypeAPIKey:
return ctx.Actor, false
case authTypeAdmin:
return ctx.Username, false
}
}
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
}
}
}
+419 -13
View File
@@ -20,6 +20,12 @@ import (
var manager = lxc.NewManager()
const (
clicdBackupDir = "/root/clicd-backups"
clicdNewBinaryPath = "/usr/local/bin/clicd.new"
libvirtDefaultNetworkMarker = "/var/lib/clicd/kvm/default-network.created"
)
// Run starts the CLI interface.
func Run() {
reader := bufio.NewReader(os.Stdin)
@@ -198,11 +204,18 @@ func cliCreateContainer(reader *bufio.Reader) {
container := config.FindContainerByName(name)
fmt.Printf("容器 %s 创建成功\n", name)
if container != nil {
fmt.Printf("SSH: root / %s, port %d -> 22\n", container.SSHPassword, container.SSHPort)
fmt.Print(formatSSHAccess(container.SSHPort))
}
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) {
id, name := selectContainer(reader, "开机")
if id == 0 {
@@ -532,13 +545,14 @@ func upgradeFromReleaseAsset(assetURL, latest string) error {
return err
}
backupDir := "/root/clicd-backups"
backupDir := clicdBackupDir
if err := os.MkdirAll(backupDir, 0700); err != nil {
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 := 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)
}
fmt.Printf("旧版本已备份: %s\n", backupPath)
@@ -548,8 +562,8 @@ func upgradeFromReleaseAsset(assetURL, latest string) error {
if err := stopService("clicd"); err != nil {
fmt.Printf("停止 Web 服务失败,继续尝试替换: %v\n", err)
}
tmpBin := "/usr/local/bin/clicd.new"
if err := copyFile(newBinary, tmpBin, 0755); err != nil {
tmpBin := clicdNewBinaryPath
if err := copyFileToUpgradeTemp(newBinary, 0755); err != nil {
return err
}
if err := os.Rename(tmpBin, "/usr/local/bin/clicd"); err != nil {
@@ -614,25 +628,69 @@ func findFile(root, name string) (string, error) {
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)
if err != nil {
out.Close()
return err
}
defer in.Close()
out, err := os.OpenFile(dst, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, mode)
if err != nil {
if _, err := io.Copy(out, in); err != nil {
out.Close()
return err
}
if _, err := io.Copy(out, in); err != nil {
if err := out.Chmod(mode); err != nil {
out.Close()
return err
}
if err := out.Close(); err != nil {
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 {
@@ -681,7 +739,7 @@ func cliImportExistingContainers() {
func cliUninstall(reader *bufio.Reader) {
fmt.Println("\n--- 卸载 CLICD ---")
fmt.Println("将删除 CLICD 服务和 /usr/local/bin/clicd。")
fmt.Println("同时会删除 /root/.clicd、/var/lib/lxc 下全部 LXC 容器,以及 /var/cache/lxc 镜像缓存。")
fmt.Println("同时会删除 /root/.clicd、/var/lib/lxc、/var/lib/clicd、镜像缓存、备份、临时文件、/swapfile 和 CLICD 网络规则。")
if os.Geteuid() != 0 {
fmt.Println("卸载需要 root 权限。")
@@ -696,6 +754,11 @@ func cliUninstall(reader *bufio.Reader) {
}
destroyAllLXCContainers()
destroyAllKVMDomains()
removeCLICDLibvirtDefaultNetwork()
cleanupCLICDNetworking()
removeCLICDHostHooks()
removeCLICDQuotaRecords()
stopAndRemoveService()
removePath("/usr/local/bin/clicd")
removePath("/etc/sysctl.d/99-clicd.conf")
@@ -703,13 +766,18 @@ func cliUninstall(reader *bufio.Reader) {
removePath("/var/log/clicd.err")
removePath("/root/.clicd")
removePath("/var/lib/lxc")
removePath("/var/lib/clicd")
removePath("/var/cache/lxc")
removePath("/var/cache/clicd")
removePath("/root/clicd-backups")
removeCLICDTmpFiles()
removeCLICDSwapfile()
reloadSysctl()
fmt.Println()
fmt.Println("CLICD 已卸载。")
fmt.Println("服务、二进制、配置、容器和 LXC 镜像缓存均已删除。")
fmt.Println("服务、二进制、配置、容器/虚拟机、本地镜像、缓存、备份、临时文件和 CLICD 网络规则均已删除。")
}
func destroyAllLXCContainers() {
@@ -730,6 +798,326 @@ 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 removeCLICDLibvirtDefaultNetwork() {
if !commandExists("virsh") || !fileExists(libvirtDefaultNetworkMarker) {
return
}
if libvirtDefaultUsedByNonCLICDDomain() {
fmt.Println("检测到非 CLICD 虚拟机仍在使用 libvirt default 网络,已保留 default/virbr0。")
return
}
fmt.Println("Removing CLICD-created libvirt default network...")
runQuiet("virsh", "net-destroy", "default")
runQuiet("virsh", "net-undefine", "default")
removePath(libvirtDefaultNetworkMarker)
}
func libvirtDefaultUsedByNonCLICDDomain() bool {
if !commandExists("virsh") {
return false
}
out, err := exec.Command("virsh", "list", "--all", "--name").Output()
if err != nil {
return false
}
for _, line := range strings.Split(string(out), "\n") {
name := strings.TrimSpace(line)
if name == "" || isCLICDKVMDomain(name) {
continue
}
if usesLibvirtDefaultNetwork(name) {
return true
}
}
return false
}
func usesLibvirtDefaultNetwork(domain string) bool {
out, err := exec.Command("virsh", "domiflist", domain).Output()
if err != nil {
return false
}
for _, line := range strings.Split(string(out), "\n") {
fields := strings.Fields(line)
for _, field := range fields {
if field == "default" || field == "virbr0" {
return true
}
}
}
return false
}
func cleanupCLICDNetworking() {
removeCLICDNATRules()
cleanupCLICDIPv6Runtime()
cleanupCLICDIPv6BridgeRoutes()
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 cleanupCLICDIPv6Runtime() {
if config.AppConfig == nil {
return
}
for _, c := range config.AppConfig.Containers {
cleanupCLICDContainerIPv6(c)
}
}
func cleanupCLICDContainerIPv6(c config.Container) {
bridge := "lxcbr0"
if c.IsKVM() {
bridge = "virbr0"
}
mac := strings.ToLower(strings.TrimSpace(c.MACAddress))
if mac != "" && bridge == "virbr0" {
deleteIP6FilterRule("FORWARD", "-i", bridge, "-m", "mac", "--mac-source", mac, "-j", "DROP")
}
if strings.TrimSpace(c.IPv6) == "" {
return
}
addr := strings.TrimSpace(c.IPv6)
if slash := strings.Index(addr, "/"); slash >= 0 {
addr = addr[:slash]
}
source := strings.TrimSpace(c.IPv6)
if !strings.Contains(source, "/") {
source += "/128"
}
deleteIP6NATSource(source)
deleteIP6FilterRule("FORWARD", "-i", bridge, "-s", source, "-j", "ACCEPT")
deleteIP6FilterRule("FORWARD", "-o", bridge, "-d", source, "-j", "ACCEPT")
if mac != "" && bridge == "virbr0" {
deleteIP6FilterRule("FORWARD", "-i", bridge, "-m", "mac", "--mac-source", mac, "-s", source, "-j", "ACCEPT")
deleteIP6FilterRule("FORWARD", "-i", bridge, "-m", "mac", "--mac-source", mac, "-j", "DROP")
}
runQuiet("ip", "-6", "route", "del", source, "dev", bridge)
if strings.TrimSpace(c.IPv6Interface) != "" {
runQuiet("ip", "-6", "neigh", "del", "proxy", addr, "dev", c.IPv6Interface)
}
}
func cleanupCLICDIPv6BridgeRoutes() {
if !commandExists("ip") {
return
}
for _, bridge := range []string{"lxcbr0", "virbr0"} {
out, err := exec.Command("ip", "-6", "route", "show", "dev", bridge).Output()
if err == nil {
for _, line := range strings.Split(string(out), "\n") {
fields := strings.Fields(line)
if len(fields) == 0 || !strings.HasSuffix(fields[0], "/128") {
continue
}
source := fields[0]
addr := strings.TrimSuffix(source, "/128")
deleteIP6NATSource(source)
deleteIP6FilterRule("FORWARD", "-i", bridge, "-s", source, "-j", "ACCEPT")
deleteIP6FilterRule("FORWARD", "-o", bridge, "-d", source, "-j", "ACCEPT")
removeProxyNDPForAddress(addr)
runQuiet("ip", "-6", "route", "del", source, "dev", bridge)
}
}
runQuiet("ip", "-6", "addr", "del", "fe80::1/64", "dev", bridge)
}
}
func removeProxyNDPForAddress(addr string) {
out, err := exec.Command("ip", "-6", "neigh", "show", "proxy").Output()
if err != nil {
return
}
for _, line := range strings.Split(string(out), "\n") {
fields := strings.Fields(line)
if len(fields) == 0 || fields[0] != addr {
continue
}
for i := 0; i+1 < len(fields); i++ {
if fields[i] == "dev" {
runQuiet("ip", "-6", "neigh", "del", "proxy", addr, "dev", fields[i+1])
}
}
}
}
func deleteIP6NATSource(source string) {
if !commandExists("ip6tables") || strings.TrimSpace(source) == "" {
return
}
for {
out, err := exec.Command("ip6tables", "-t", "nat", "-S", "POSTROUTING").Output()
if err != nil {
return
}
deleted := false
for _, line := range strings.Split(string(out), "\n") {
if !strings.Contains(line, "-s "+source) || !strings.Contains(line, " -j MASQUERADE") {
continue
}
fields := strings.Fields(line)
if len(fields) == 0 || fields[0] != "-A" {
continue
}
fields[0] = "-D"
args := append([]string{"-t", "nat"}, fields...)
deleted = runCommandOK("ip6tables", args...)
break
}
if !deleted {
return
}
}
}
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 deleteIP6FilterRule(args ...string) {
fullArgs := append([]string{"-D"}, args...)
for runCommandOK("ip6tables", 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) {
unmountPathTree(path)
detachLoopDevices(path)
@@ -819,6 +1207,16 @@ func removePath(path string) {
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() {
if commandExists("sysctl") {
runQuiet("sysctl", "--system")
@@ -830,10 +1228,18 @@ func commandExists(name string) bool {
return err == nil
}
func runCommandOK(name string, args ...string) bool {
return exec.Command(name, args...).Run() == nil
}
func runQuiet(name string, args ...string) {
_ = exec.Command(name, args...).Run()
}
func shellQuote(value string) string {
return "'" + strings.ReplaceAll(value, "'", "'\"'\"'") + "'"
}
func restartWebPanelForConfigChange() {
if err := restartService("clicd"); err != nil {
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)
}
}
+373 -119
View File
@@ -3,7 +3,6 @@ package config
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"path/filepath"
@@ -34,6 +33,8 @@ type SavedTask struct {
TemplateID string `json:"template_id,omitempty"`
Config string `json:"config,omitempty"`
User string `json:"user,omitempty"`
IP string `json:"ip,omitempty"`
UserAgent string `json:"user_agent,omitempty"`
}
// SavedLoginLog for persisting login logs
@@ -47,21 +48,22 @@ type SavedLoginLog struct {
// AuditLog represents an operation log entry
type AuditLog struct {
Time string `json:"time"`
Action string `json:"action"`
Target string `json:"target"`
Detail string `json:"detail"`
User string `json:"user"`
Time string `json:"time"`
Action string `json:"action"`
Target string `json:"target"`
Detail string `json:"detail"`
User string `json:"user"`
IP string `json:"ip,omitempty"`
UserAgent string `json:"user_agent,omitempty"`
Success *bool `json:"success,omitempty"`
Error string `json:"error,omitempty"`
}
// OversellConfig controls host-level overselling behavior
type OversellConfig struct {
CPUOvercommit int `json:"cpu_overcommit"` // multiplier, e.g. 4 means 4x oversell
RAMOvercommit int `json:"ram_overcommit"` // multiplier
DiskOvercommit int `json:"disk_overcommit"` // multiplier
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
type VMReadinessCheck struct {
Key string `json:"key"`
Label string `json:"label"`
OK bool `json:"ok"`
Detail string `json:"detail,omitempty"`
}
// Container represents an LXC container configuration
@@ -69,7 +71,11 @@ type Container struct {
ID int `json:"id"`
UUID string `json:"uuid"`
Name string `json:"name"`
Virtualization string `json:"virtualization,omitempty"`
LXCName string `json:"lxc_name,omitempty"`
KVMName string `json:"kvm_name,omitempty"`
DiskImage string `json:"disk_image,omitempty"`
MACAddress string `json:"mac_address,omitempty"`
Template string `json:"template"`
VCPU float64 `json:"vcpu"`
RAMMB int `json:"ram_mb"`
@@ -103,6 +109,31 @@ type Container struct {
SnapshotScheduleLastRun string `json:"snapshot_schedule_last_run"`
SnapshotScheduleNextRun string `json:"snapshot_schedule_next_run"`
SnapshotScheduleCreatedBy string `json:"snapshot_schedule_created_by"`
PolicyBlocked bool `json:"policy_blocked"`
PolicyBlockedReason string `json:"policy_blocked_reason,omitempty"`
PolicyBlockedAt string `json:"policy_blocked_at,omitempty"`
}
const (
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})
@@ -113,15 +144,28 @@ func (c *Container) LxcName() string {
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
type ApiKeyConfig struct {
ID string `json:"id"`
Name string `json:"name"`
KeyHash string `json:"key_hash"`
Prefix string `json:"prefix"`
IPWhitelist string `json:"ip_whitelist"`
CreatedAt string `json:"created_at"`
LastUsed string `json:"last_used"`
ID string `json:"id"`
Name string `json:"name"`
KeyHash string `json:"key_hash"`
Prefix string `json:"prefix"`
IPWhitelist string `json:"ip_whitelist"`
CreatedAt string `json:"created_at"`
LastUsed string `json:"last_used"`
Scopes []string `json:"scopes,omitempty"`
ExpiresAt string `json:"expires_at,omitempty"`
Disabled bool `json:"disabled,omitempty"`
ContainerUUIDs []string `json:"container_uuids,omitempty"`
LastUsedIP string `json:"last_used_ip,omitempty"`
}
// DeleteApiKey removes an API key by ID
@@ -139,13 +183,14 @@ func DeleteApiKey(id string) {
type SubUser struct {
ID string `json:"id"`
Username string `json:"username"`
Password string `json:"-"`
Password string `json:"password,omitempty"`
PassHash string `json:"pass_hash"`
ContainerNames []string `json:"container_names"`
ContainerUUIDs []string `json:"container_uuids,omitempty"`
Token string `json:"-"`
AccessCode string `json:"access_code"`
CreatedAt string `json:"created_at"`
TokenVersion int `json:"token_version"`
}
type Snapshot struct {
@@ -160,26 +205,46 @@ type Snapshot struct {
SizeBytes int64 `json:"size_bytes"`
}
const (
SSLModeDisabled = "disabled"
SSLModeLetsEncrypt = "letsencrypt"
SSLModeSelfSigned = "self_signed"
SSLModeUploaded = "uploaded"
)
type SSLConfig struct {
Enabled bool `json:"enabled"`
Mode string `json:"mode"`
Target string `json:"target"`
Email string `json:"email,omitempty"`
CertPath string `json:"cert_path,omitempty"`
KeyPath string `json:"key_path,omitempty"`
LastIssuedAt string `json:"last_issued_at,omitempty"`
LastError string `json:"last_error,omitempty"`
}
// ClicdConfig is the main configuration structure
type ClicdConfig struct {
AdminUser string `json:"admin_user"`
AdminPassHash string `json:"admin_pass_hash"`
JWTSecret string `json:"jwt_secret"`
Port int `json:"port"`
DataDir string `json:"data_dir"`
Containers []Container `json:"containers"`
NextContainerID int `json:"next_container_id"`
NextVNCPort int `json:"next_vnc_port"`
NextSSHPort int `json:"next_ssh_port"`
SetupComplete bool `json:"setup_complete"`
Oversell OversellConfig `json:"oversell"`
SubUsers []SubUser `json:"sub_users"`
ApiKeys []ApiKeyConfig `json:"api_keys"`
AuditLogs []AuditLog `json:"audit_logs"`
Tasks []SavedTask `json:"tasks"`
LoginLogs []SavedLoginLog `json:"login_logs"`
EnabledImages []string `json:"enabled_images"`
Snapshots []Snapshot `json:"snapshots"`
AdminUser string `json:"admin_user"`
AdminPassHash string `json:"admin_pass_hash"`
JWTSecret string `json:"jwt_secret"`
Port int `json:"port"`
DataDir string `json:"data_dir"`
Containers []Container `json:"containers"`
NextContainerID int `json:"next_container_id"`
NextVNCPort int `json:"next_vnc_port"`
NextSSHPort int `json:"next_ssh_port"`
SetupComplete bool `json:"setup_complete"`
SubUsers []SubUser `json:"sub_users"`
ApiKeys []ApiKeyConfig `json:"api_keys"`
AuditLogs []AuditLog `json:"audit_logs"`
Tasks []SavedTask `json:"tasks"`
LoginLogs []SavedLoginLog `json:"login_logs"`
EnabledImages []string `json:"enabled_images"`
Snapshots []Snapshot `json:"snapshots"`
SecurityAutoShutdown bool `json:"security_auto_shutdown"`
SSL SSLConfig `json:"ssl"`
SSLCertificates map[string]SSLConfig `json:"ssl_certificates"`
}
var configPath string
@@ -242,102 +307,221 @@ func InitConfig() (*ClicdConfig, error) {
dataDir := getDataDir()
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 {
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) {
// First run: generate new config
adminUser := "admin"
adminPass := generateRandomString(16)
jwtSecret := generateRandomString(32)
hash, err := bcrypt.GenerateFromPassword([]byte(adminPass), bcrypt.DefaultCost)
if err != nil {
return nil, fmt.Errorf("failed to hash password: %v", err)
cfg, ok, err := loadConfigFromDB()
if err != nil {
return nil, err
}
if ok {
AppConfig = cfg
changed := normalizeConfigDefaults(dataDir)
if migrateLoadedConfig() {
changed = true
}
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 changed {
if err := SaveConfig(); err != nil {
return nil, err
}
}
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
}
// Load existing config
data, err := os.ReadFile(cfgPath)
legacy, ok, err := loadLegacyJSONConfig(cfgPath)
if err != nil {
return nil, fmt.Errorf("failed to read config: %v", err)
return nil, err
}
if ok {
AppConfig = legacy
normalizeConfigDefaults(dataDir)
migrateLoadedConfig()
// Always save legacy JSON data into SQLite.
if err := SaveConfig(); err != nil {
return nil, err
}
return AppConfig, nil
}
AppConfig = &ClicdConfig{}
if err := json.Unmarshal(data, AppConfig); err != nil {
return nil, fmt.Errorf("failed to parse config: %v", err)
adminUser := "admin"
adminPass := generateRandomString(16)
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) bool {
changed := false
if AppConfig.Port == 0 {
AppConfig.Port = 8999
changed = true
}
if AppConfig.NextVNCPort == 0 {
AppConfig.NextVNCPort = 5900
changed = true
}
if AppConfig.NextSSHPort == 0 {
AppConfig.NextSSHPort = 22000
changed = true
}
if AppConfig.NextContainerID == 0 {
AppConfig.NextContainerID = 1
changed = true
}
if AppConfig.DataDir == "" {
AppConfig.DataDir = dataDir
changed = true
}
if AppConfig.Containers == nil {
AppConfig.Containers = make([]Container, 0)
changed = true
}
if AppConfig.Snapshots == nil {
AppConfig.Snapshots = make([]Snapshot, 0)
changed = true
}
if AppConfig.Oversell.SubUserSnapshotLimit <= 0 {
AppConfig.Oversell.SubUserSnapshotLimit = 3
if AppConfig.SubUsers == nil {
AppConfig.SubUsers = make([]SubUser, 0)
changed = true
}
if AppConfig.ApiKeys == nil {
AppConfig.ApiKeys = make([]ApiKeyConfig, 0)
changed = true
} else {
for i := range AppConfig.ApiKeys {
if len(AppConfig.ApiKeys[i].Scopes) == 0 {
AppConfig.ApiKeys[i].Scopes = []string{"*"}
changed = true
}
}
}
if AppConfig.AuditLogs == nil {
AppConfig.AuditLogs = make([]AuditLog, 0)
changed = true
}
if AppConfig.Tasks == nil {
AppConfig.Tasks = make([]SavedTask, 0)
changed = true
}
if AppConfig.LoginLogs == nil {
AppConfig.LoginLogs = make([]SavedLoginLog, 0)
changed = true
}
if AppConfig.EnabledImages == nil {
AppConfig.EnabledImages = make([]string, 0)
changed = true
}
if normalizeSSLDefaults() {
changed = true
}
return changed
}
func normalizeSSLDefaults() bool {
changed := false
previousMode := AppConfig.SSL.Mode
AppConfig.SSL.Mode = NormalizeSSLMode(AppConfig.SSL.Mode)
if AppConfig.SSL.Mode != previousMode {
changed = true
}
if AppConfig.SSL.Mode == SSLModeDisabled {
if AppConfig.SSL.Enabled {
changed = true
}
AppConfig.SSL.Enabled = false
}
if AppConfig.SSLCertificates == nil {
AppConfig.SSLCertificates = map[string]SSLConfig{}
changed = true
}
for mode, cert := range AppConfig.SSLCertificates {
cert.Mode = NormalizeSSLMode(cert.Mode)
if cert.Mode == SSLModeDisabled {
delete(AppConfig.SSLCertificates, mode)
changed = true
continue
}
if AppConfig.SSLCertificates[cert.Mode] != cert {
changed = true
}
AppConfig.SSLCertificates[cert.Mode] = cert
if mode != cert.Mode {
delete(AppConfig.SSLCertificates, mode)
changed = true
}
}
if AppConfig.SSL.Mode != SSLModeDisabled && AppConfig.SSL.CertPath != "" && AppConfig.SSL.KeyPath != "" {
cert := AppConfig.SSL
cert.Enabled = false
if AppConfig.SSLCertificates[cert.Mode] != cert {
changed = true
}
AppConfig.SSLCertificates[cert.Mode] = cert
}
return changed
}
func NormalizeSSLMode(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case SSLModeLetsEncrypt:
return SSLModeLetsEncrypt
case SSLModeSelfSigned:
return SSLModeSelfSigned
case SSLModeUploaded:
return SSLModeUploaded
default:
return SSLModeDisabled
}
}
func migrateLoadedConfig() bool {
changed := ensureContainerUUIDs()
if ensureContainerVirtualization() {
changed = true
}
if ensureContainerPortMappingLimits() {
changed = true
}
@@ -353,13 +537,19 @@ func InitConfig() (*ClicdConfig, error) {
if removeLegacyVNCMappings() {
changed = true
}
if changed {
if err := SaveConfig(); err != nil {
return nil, err
return changed
}
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 AppConfig, nil
return changed
}
func ensureContainerSnapshotScheduleDefaults() bool {
@@ -414,13 +604,9 @@ func ensureContainerPortMappingLimits() bool {
func ensureContainerSnapshotLimits() bool {
changed := false
legacyLimit := AppConfig.Oversell.SubUserSnapshotLimit
if legacyLimit <= 0 {
legacyLimit = DefaultSnapshotLimit
}
for i := range AppConfig.Containers {
if AppConfig.Containers[i].SnapshotLimit <= 0 {
AppConfig.Containers[i].SnapshotLimit = legacyLimit
AppConfig.Containers[i].SnapshotLimit = DefaultSnapshotLimit
changed = true
}
}
@@ -437,10 +623,6 @@ func migrateSubUsers() bool {
changed = true
}
}
if su.Password != "" {
su.Password = ""
changed = true
}
if su.Token != "" {
su.Token = ""
changed = true
@@ -506,11 +688,7 @@ func removeLegacyVNCMappings() bool {
// SaveConfig saves configuration to disk
func SaveConfig() error {
data, err := json.MarshalIndent(AppConfig, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal config: %v", err)
}
return os.WriteFile(getConfigPath(), data, 0600)
return saveConfigToDB()
}
// AddContainer adds a container to the config
@@ -518,6 +696,7 @@ func AddContainer(c Container) {
if c.UUID == "" {
c.UUID = NewContainerUUID()
}
c.Virtualization = NormalizeVirtualization(c.Virtualization)
AppConfig.Containers = append(AppConfig.Containers, c)
SaveConfig()
}
@@ -536,6 +715,8 @@ func RemoveContainer(id int) bool {
if c.ID == id {
removeSubUserContainerAccess(c.Name, c.UUID)
removeContainerSnapshotMetadata(id)
// Clear snapshot schedule for this container
clearContainerSnapshotSchedule(&AppConfig.Containers[i])
AppConfig.Containers = append(AppConfig.Containers[:i], AppConfig.Containers[i+1:]...)
SaveConfig()
return true
@@ -544,6 +725,15 @@ func RemoveContainer(id int) bool {
return false
}
func clearContainerSnapshotSchedule(c *Container) {
c.SnapshotScheduleEnabled = false
c.SnapshotScheduleIntervalHours = 0
c.SnapshotScheduleTime = ""
c.SnapshotScheduleLastRun = ""
c.SnapshotScheduleNextRun = ""
c.SnapshotScheduleCreatedBy = ""
}
func AddSnapshot(snapshot Snapshot) {
AppConfig.Snapshots = append(AppConfig.Snapshots, snapshot)
SaveConfig()
@@ -674,20 +864,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
func UpdateVNC(containers []Container) {
AppConfig.Containers = containers
SaveConfig()
}
// AllocateSSHPort allocates a new SSH port
// AllocateSSHPort allocates a new SSH port, skipping ports already used by any container
func AllocateSSHPort() int {
used := collectAllHostPorts()
port := AppConfig.NextSSHPort
AppConfig.NextSSHPort++
for used[port] {
port++
}
AppConfig.NextSSHPort = port + 1
SaveConfig()
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)
func IsValidContainerName(name string) bool {
return IsValidContainerNameSyntax(name)
@@ -723,6 +944,26 @@ func AddAuditLog(action, target, detail, user string) {
SaveConfig()
}
func AddAuditLogFull(action, target, detail, user, ip, userAgent string, success bool, errMsg string) {
s := success
log := AuditLog{
Time: time.Now().Format("2006-01-02 15:04:05"),
Action: action,
Target: target,
Detail: detail,
User: user,
IP: ip,
UserAgent: userAgent,
Success: &s,
Error: errMsg,
}
AppConfig.AuditLogs = append(AppConfig.AuditLogs, log)
if len(AppConfig.AuditLogs) > 500 {
AppConfig.AuditLogs = AppConfig.AuditLogs[len(AppConfig.AuditLogs)-500:]
}
SaveConfig()
}
// SaveTasks persists the task queue to config
func SaveTasks(tasks []SavedTask) {
AppConfig.Tasks = tasks
@@ -760,6 +1001,19 @@ func CleanStaleContainers() {
valid := make([]Container, 0)
changed := false
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()
if _, err := os.Stat(lxcDir); os.IsNotExist(err) {
fmt.Printf("Cleaning stale container config: %s (LXC dir not found)\n", c.LxcName())
+962
View File
@@ -0,0 +1,962 @@
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 encodeStringSlice(values []string) string {
if len(values) == 0 {
return ""
}
data, err := json.Marshal(values)
if err != nil {
return ""
}
return string(data)
}
func decodeStringSlice(raw string) []string {
if strings.TrimSpace(raw) == "" {
return nil
}
var values []string
if err := json.Unmarshal([]byte(raw), &values); err != nil {
return nil
}
return values
}
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,
scopes TEXT,
expires_at TEXT,
disabled INTEGER,
container_uuids TEXT,
last_used_ip 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,
ip TEXT,
user_agent 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 ensureSchemaMigrations()
}
func ensureSchemaMigrations() error {
for _, column := range []struct {
table string
name string
def string
}{
{"api_keys", "scopes", "TEXT"},
{"api_keys", "expires_at", "TEXT"},
{"api_keys", "disabled", "INTEGER"},
{"api_keys", "container_uuids", "TEXT"},
{"api_keys", "last_used_ip", "TEXT"},
{"tasks", "ip", "TEXT"},
{"tasks", "user_agent", "TEXT"},
} {
if err := ensureColumn(column.table, column.name, column.def); err != nil {
return err
}
}
return nil
}
func ensureColumn(table, name, def string) error {
rows, err := db.Query("PRAGMA table_info(" + table + ")")
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var cid int
var columnName, columnType string
var notNull, pk int
var defaultValue interface{}
if err := rows.Scan(&cid, &columnName, &columnType, &notNull, &defaultValue, &pk); err != nil {
return err
}
if columnName == name {
return nil
}
}
if err := rows.Err(); err != nil {
return err
}
_, err = db.Exec("ALTER TABLE " + table + " ADD COLUMN " + name + " " + def)
return err
}
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 raw := strings.TrimSpace(meta["ssl"]); raw != "" {
_ = json.Unmarshal([]byte(raw), &cfg.SSL)
}
if raw := strings.TrimSpace(meta["ssl_certificates"]); raw != "" {
_ = json.Unmarshal([]byte(raw), &cfg.SSLCertificates)
}
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 {
sslJSON, _ := json.Marshal(AppConfig.SSL)
sslCertificatesJSON, _ := json.Marshal(AppConfig.SSLCertificates)
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),
"ssl": string(sslJSON),
"ssl_certificates": string(sslCertificatesJSON),
"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 {
scopes := encodeStringSlice(k.Scopes)
containerUUIDs := encodeStringSlice(k.ContainerUUIDs)
if _, err := tx.Exec(`INSERT INTO api_keys(id, name, key_hash, prefix, ip_whitelist, created_at, last_used, scopes, expires_at, disabled, container_uuids, last_used_ip)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, k.ID, k.Name, k.KeyHash, k.Prefix, k.IPWhitelist, k.CreatedAt, k.LastUsed, scopes, k.ExpiresAt, boolInt(k.Disabled), containerUUIDs, k.LastUsedIP); 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, ip, user_agent,
cfg_name, cfg_virtualization, cfg_template_id, cfg_vcpu, cfg_cpu_percent, cfg_ram_mb, cfg_disk_gb,
cfg_network_bw_mbps, cfg_monthly_traffic_gb, cfg_traffic_mode, cfg_traffic_in_gb,
cfg_traffic_out_gb, cfg_io_speed_mbps, cfg_port_mapping_count, cfg_snapshot_limit,
cfg_assign_ipv6, cfg_expires_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
task.ID, task.Type, task.ContainerID, task.ContainerName, task.Status, task.Error, task.CreatedAt, task.TemplateID, task.User, task.IP, task.UserAgent,
cfg.Name, cfg.Virtualization, cfg.TemplateID, cfg.VCPU, cfg.CPUPercent, cfg.RAMMB, cfg.DiskGB,
cfg.NetworkBWMbps, cfg.MonthlyTrafficGB, cfg.TrafficMode, cfg.TrafficInGB,
cfg.TrafficOutGB, cfg.IOSpeedMBps, cfg.PortMappingCount, cfg.SnapshotLimit,
boolInt(cfg.AssignIPv6), cfg.ExpiresAt,
); 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, scopes, expires_at, disabled, container_uuids, last_used_ip 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
var scopes, expiresAt, containerUUIDs, lastUsedIP sql.NullString
var disabled sql.NullInt64
if err := rows.Scan(&k.ID, &k.Name, &k.KeyHash, &k.Prefix, &k.IPWhitelist, &k.CreatedAt, &k.LastUsed, &scopes, &expiresAt, &disabled, &containerUUIDs, &lastUsedIP); err != nil {
return nil, err
}
k.Scopes = decodeStringSlice(scopes.String)
k.ExpiresAt = expiresAt.String
k.Disabled = disabled.Valid && disabled.Int64 != 0
k.ContainerUUIDs = decodeStringSlice(containerUUIDs.String)
k.LastUsedIP = lastUsedIP.String
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, ip, user_agent,
cfg_name, cfg_virtualization, cfg_template_id, cfg_vcpu, cfg_cpu_percent, cfg_ram_mb, cfg_disk_gb,
cfg_network_bw_mbps, cfg_monthly_traffic_gb, cfg_traffic_mode, cfg_traffic_in_gb,
cfg_traffic_out_gb, cfg_io_speed_mbps, cfg_port_mapping_count, cfg_snapshot_limit,
cfg_assign_ipv6, cfg_expires_at
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
var ip, userAgent sql.NullString
if err := rows.Scan(
&t.ID, &t.Type, &t.ContainerID, &t.ContainerName, &t.Status, &t.Error, &t.CreatedAt, &t.TemplateID, &t.User, &ip, &userAgent,
&cfg.Name, &cfg.Virtualization, &cfg.TemplateID, &cfg.VCPU, &cfg.CPUPercent, &cfg.RAMMB, &cfg.DiskGB,
&cfg.NetworkBWMbps, &cfg.MonthlyTrafficGB, &cfg.TrafficMode, &cfg.TrafficInGB,
&cfg.TrafficOutGB, &cfg.IOSpeedMBps, &cfg.PortMappingCount, &cfg.SnapshotLimit,
&assignIPv6, &cfg.ExpiresAt,
); err != nil {
return nil, err
}
t.IP = ip.String
t.UserAgent = userAgent.String
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.
func (m *Manager) StopExpiredContainers(now time.Time) {
for _, container := range config.AppConfig.Containers {
if !isContainerExpired(container, now) {
if container.IsKVM() || !isContainerExpired(container, now) {
continue
}
@@ -53,7 +53,7 @@ func (m *Manager) StopTrafficExceededContainers(now time.Time) {
saved := false
for i := range config.AppConfig.Containers {
c := &config.AppConfig.Containers[i]
if c.Status != "running" {
if c.IsKVM() || c.Status != "running" {
continue
}
+567 -93
View File
@@ -1,6 +1,7 @@
package lxc
import (
"bufio"
"context"
"crypto/rand"
"encoding/hex"
@@ -10,11 +11,11 @@ import (
"os"
"os/exec"
"path/filepath"
"reflect"
"regexp"
"strconv"
"strings"
"sync"
"syscall"
"time"
"clicd/internal/config"
@@ -72,11 +73,17 @@ func (m *Manager) WarmRunningContainersSSH() {
containers := append([]config.Container(nil), config.AppConfig.Containers...)
for _, container := range containers {
c := container
if c.IsKVM() {
continue
}
status, err := m.GetContainerStatus(c.LxcName())
if err != nil || status != "running" {
continue
}
config.UpdateContainerStatus(c.ID, "running")
if c.IP != "" && m.containerPortListening(c.LxcName(), 22) {
continue
}
m.WarmSSHAsync(c.ID, "running container scan")
}
}
@@ -98,6 +105,11 @@ func (m *Manager) updateAllRates() {
for i := range config.AppConfig.Containers {
c := &config.AppConfig.Containers[i]
if c.IsKVM() {
delete(lastUsage, c.VirshName())
delete(rateCache, c.VirshName())
continue
}
if c.Status != "running" {
delete(lastUsage, c.LxcName())
delete(rateCache, c.LxcName())
@@ -105,19 +117,23 @@ func (m *Manager) updateAllRates() {
}
lxcName := c.LxcName()
// Read raw bytes
memUsage := readIntCommand(fmt.Sprintf(
"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/memory/lxc/%[1]s/memory.usage_in_bytes 2>/dev/null || echo 0", shellQuote(lxcName)))
// Cache init PID once per scan so getContainerNetworkBytes / getContainerDiskIOBytes
// don't each fork lxc-info separately.
initPID := m.getContainerInitPID(lxcName)
cpuUsec := uint64(readIntCommand(fmt.Sprintf(
"(cat /sys/fs/cgroup/lxc/%[1]s/cpu.stat 2>/dev/null || "+
"cat /sys/fs/cgroup/lxc.payload.%[1]s/cpu.stat 2>/dev/null) | "+
"awk '/usage_usec/ {print $2; found=1} END {if (!found) print 0}'", shellQuote(lxcName))))
// Read memory from cgroup directly (no shell fork)
memUsage := readCgroupFile(lxcName,
"/sys/fs/cgroup/lxc/%s/memory.current",
"/sys/fs/cgroup/lxc.payload.%s/memory.current",
"/sys/fs/cgroup/memory/lxc/%s/memory.usage_in_bytes")
rxBytes, txBytes := m.getContainerNetworkBytes(lxcName)
readBytes, writeBytes := m.getContainerDiskIOBytes(lxcName)
// Read cpu usage from cgroup directly (no shell | awk fork)
cpuUsec := readCgroupCPUUsec(lxcName,
"/sys/fs/cgroup/lxc/%s/cpu.stat",
"/sys/fs/cgroup/lxc.payload.%s/cpu.stat")
rxBytes, txBytes := getNetworkBytesForPID(initPID)
readBytes, writeBytes := getDiskIOBytesForPID(initPID)
now := time.Now()
sample := containerUsageSample{
@@ -203,6 +219,7 @@ func NewManager() *Manager {
// ContainerConfig defines container creation parameters
type ContainerConfig struct {
Name string `json:"name"`
Virtualization string `json:"virtualization,omitempty"`
TemplateID string `json:"template_id"`
VCPU float64 `json:"vcpu"`
CPUPercent int `json:"cpu_percent"`
@@ -337,6 +354,7 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
ID: id,
UUID: config.NewContainerUUID(),
Name: cfg.Name,
Virtualization: config.VirtualizationLXC,
Template: cfg.TemplateID,
VCPU: cfg.VCPU,
RAMMB: cfg.RAMMB,
@@ -372,20 +390,21 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
fmt.Printf("Warning: failed to install IPv6 init in %s: %v\n", lxcName, err)
}
}
if err := m.preconfigureSSH(rootfsPath, sshPassword, cfg.TemplateID); err != nil {
if err := m.preconfigureSSH(rootfsPath, cfg.TemplateID); err != nil {
fmt.Printf("Warning: failed to pre-configure SSH in %s: %v\n", lxcName, err)
}
if err := m.shiftRootfsForUnprivileged(lxcName); err != nil {
_ = m.cleanupContainerStorage(lxcName)
config.RemoveContainer(id)
return err
}
// Set root password AFTER shiftRootfsForUnprivileged,
// otherwise /etc/shadow ownership breaks and SSHD cannot authenticate.
setCmd := m.rootfsCommand(rootfsPath,
"sh", "-c", fmt.Sprintf("printf '%%s:%%s\\n' root %s | chpasswd", shellQuote(sshPassword)))
setCmd.Run()
if err := m.setRootfsPassword(rootfsPath, sshPassword); err != nil {
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)
return nil
@@ -410,7 +429,7 @@ func (m *Manager) preconfigureNetwork(rootfsPath, templateID string) {
content := "auto lo\niface lo inet loopback\n\nauto eth0\niface eth0 inet dhcp\n"
_ = os.MkdirAll(filepath.Dir(interfaces), 0755)
_ = 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
}
@@ -432,7 +451,7 @@ method=ignore
path := filepath.Join(nmDir, "eth0.nmconnection")
_ = 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")
@@ -447,20 +466,23 @@ IPv6AcceptRA=no
_ = os.WriteFile(filepath.Join(networkdDir, "10-eth0.network"), []byte(network), 0644)
}
if !isRHELFamily {
_ = exec.Command("chroot", rootfsPath, "systemctl", "enable", "systemd-networkd").Run()
_ = m.runRootfsCommand(rootfsPath, "systemctl", "enable", "systemd-networkd")
}
}
// preconfigureSSH installs and configures SSH directly in the rootfs before first boot.
func (m *Manager) preconfigureSSH(rootfsPath, password, templateID string) error {
func (m *Manager) preconfigureSSH(rootfsPath, templateID string) error {
_ = templateID
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second)
defer cancel()
cmd := m.rootfsCommand(rootfsPath, "sh", "-c", sshSetupScript(password, false))
cmd, err := m.rootfsCommand(rootfsPath, "sh", "-c", sshSetupScript(false))
if err != nil {
return err
}
cmd = exec.CommandContext(ctx, cmd.Path, cmd.Args[1:]...)
output, err := cmd.CombinedOutput()
if ctx.Err() == context.DeadlineExceeded {
return fmt.Errorf("timed out after 120s, output: %s", string(output))
return fmt.Errorf("timed out after 180s, output: %s", string(output))
}
if err != nil {
return fmt.Errorf("%v, output: %s", err, string(output))
@@ -970,6 +992,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)
}
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 {
uidBase, gidBase, err := unprivilegedIDMap()
if err != nil {
@@ -977,9 +1020,21 @@ func (m *Manager) shiftRootfsForUnprivileged(lxcName string) error {
}
rootfsPath := filepath.Join(m.LxcPath, lxcName, "rootfs")
marker := filepath.Join(rootfsPath, ".clicd-unprivileged-shifted")
if err := m.ensureUnprivilegedLXCPathAccess(lxcName); err != nil {
return err
}
if _, err := os.Stat(marker); err == nil {
return nil
}
m.unmountRootfsChildMounts(rootfsPath)
rootInfo, err := os.Lstat(rootfsPath)
if err != nil {
return err
}
rootDev, _, _, ok := fileStatFields(rootInfo)
if !ok {
return fmt.Errorf("failed to read rootfs device for %s", rootfsPath)
}
if err := filepath.WalkDir(rootfsPath, func(path string, _ os.DirEntry, walkErr error) error {
if walkErr != nil {
@@ -989,12 +1044,16 @@ func (m *Manager) shiftRootfsForUnprivileged(lxcName string) error {
if err != nil {
return err
}
stat, ok := info.Sys().(*syscall.Stat_t)
dev, uid, gid, ok := fileStatFields(info)
if !ok {
return fmt.Errorf("failed to read uid/gid for %s", path)
}
uid := int(stat.Uid)
gid := int(stat.Gid)
if path != rootfsPath && dev != rootDev {
if info.IsDir() {
return filepath.SkipDir
}
return nil
}
if uid >= uidBase && uid < uidBase+65536 && gid >= gidBase && gid < gidBase+65536 {
return nil
}
@@ -1004,7 +1063,7 @@ func (m *Manager) shiftRootfsForUnprivileged(lxcName string) error {
if gid >= 0 && gid < 65536 {
gid += gidBase
}
return syscall.Lchown(path, uid, gid)
return os.Lchown(path, uid, gid)
}); err != nil {
return fmt.Errorf("failed to shift rootfs ownership for unprivileged LXC: %v", err)
}
@@ -1012,7 +1071,7 @@ func (m *Manager) shiftRootfsForUnprivileged(lxcName string) error {
if err := os.WriteFile(marker, []byte("1\n"), 0644); err != nil {
return err
}
if err := syscall.Lchown(marker, uidBase, gidBase); err != nil {
if err := os.Lchown(marker, uidBase, gidBase); err != nil {
return err
}
@@ -1027,6 +1086,76 @@ func (m *Manager) shiftRootfsForUnprivileged(lxcName string) error {
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) {
rootAbs, err := filepath.Abs(rootfsPath)
if err != nil {
return
}
out, err := exec.Command("findmnt", "-R", "-n", "-o", "TARGET", rootfsPath).Output()
if err != nil {
return
}
targets := strings.Split(strings.TrimSpace(string(out)), "\n")
for i, j := 0, len(targets)-1; i < j; i, j = i+1, j-1 {
targets[i], targets[j] = targets[j], targets[i]
}
for _, target := range targets {
target = strings.TrimSpace(target)
if target == "" {
continue
}
targetAbs, err := filepath.Abs(target)
if err != nil || targetAbs == rootAbs {
continue
}
if strings.HasPrefix(targetAbs, rootAbs+string(os.PathSeparator)) {
exec.Command("umount", "-R", "-l", targetAbs).Run()
}
}
}
func (m *Manager) rootfsShifted(lxcName string) bool {
marker := filepath.Join(m.LxcPath, lxcName, "rootfs", ".clicd-unprivileged-shifted")
_, err := os.Stat(marker)
@@ -1474,11 +1603,20 @@ func (m *Manager) DestroyContainer(id int) error {
}
return fmt.Errorf("container still exists after cleanup with status %s", status)
}
snapshotDir := filepath.Join(snapshotBaseDir(), lxcName)
// Remove snapshot physical files (by container ID, not lxcName)
snapshotDir := filepath.Join(snapshotBaseDir(), strconv.Itoa(id))
if err := safePathUnder(snapshotDir, snapshotBaseDir()); err == nil {
os.RemoveAll(snapshotDir)
}
// Also remove any legacy snapshot dir that used lxcName
legacySnapshotDir := filepath.Join(snapshotBaseDir(), lxcName)
if legacySnapshotDir != snapshotDir {
if err := safePathUnder(legacySnapshotDir, snapshotBaseDir()); err == nil {
os.RemoveAll(legacySnapshotDir)
}
}
if !config.RemoveContainer(id) {
return fmt.Errorf("container destroyed but config entry was not removed: %d", id)
}
@@ -1511,18 +1649,21 @@ func (m *Manager) EnsureSSH(id int) error {
config.SaveConfig()
}
script := sshSetupScript(c.SSHPassword, true)
script := sshSetupScript(true)
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "lxc-attach", "-n", lxcName, "--", "sh", "-c", script)
output, err := cmd.CombinedOutput()
if ctx.Err() == context.DeadlineExceeded {
return fmt.Errorf("timed out configuring SSH in container %d after 90s; package manager or service startup may be stuck, output: %s", id, string(output))
return fmt.Errorf("timed out configuring SSH in container %d after 180s; package manager or service startup may be stuck, output: %s", id, string(output))
}
if err != nil {
return fmt.Errorf("failed to configure SSH in container %d: %v, output: %s", id, err, string(output))
}
if err := m.quickEnsureSSHPassword(lxcName, c.SSHPassword); err != nil {
return fmt.Errorf("failed to set SSH password in container %d: %v", id, err)
}
if c.IP == "" {
if ip, ipErr := m.GetContainerIP(lxcName); ipErr == nil && ip != "" {
@@ -1541,13 +1682,13 @@ func (m *Manager) EnsureSSH(id int) error {
}
func (m *Manager) quickEnsureSSHPassword(lxcName, password string) error {
if password == "" {
return fmt.Errorf("empty SSH password")
if err := validateRootPassword(password); err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "lxc-attach", "-n", lxcName, "--", "sh", "-c",
fmt.Sprintf("printf '%%s:%%s\\n' root %s | chpasswd", shellQuote(password)))
cmd := exec.CommandContext(ctx, "lxc-attach", "-n", lxcName, "--", "chpasswd")
cmd.Stdin = strings.NewReader(rootPasswordInput(password))
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("failed to update SSH password quickly: %v, output: %s", err, string(output))
@@ -1555,6 +1696,20 @@ func (m *Manager) quickEnsureSSHPassword(lxcName, password string) error {
return nil
}
func validateRootPassword(password string) error {
if password == "" {
return fmt.Errorf("empty SSH password")
}
if strings.ContainsAny(password, "\r\n") || strings.ContainsRune(password, '\x00') {
return fmt.Errorf("SSH password contains unsupported control characters")
}
return nil
}
func rootPasswordInput(password string) string {
return "root:" + password + "\n"
}
func (m *Manager) containerPortListening(lxcName string, port int) bool {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
@@ -1562,9 +1717,8 @@ func (m *Manager) containerPortListening(lxcName string, port int) bool {
return exec.CommandContext(ctx, "lxc-attach", "-n", lxcName, "--", "sh", "-c", check).Run() == nil
}
func sshSetupScript(password string, startService bool) string {
func sshSetupScript(startService bool) string {
script := `set -u
ROOT_PASSWORD=` + shellQuote(password) + `
# DNS setup: handle both traditional /etc/resolv.conf and systemd-resolved (Ubuntu 24.04).
# On modern distros, /etc/resolv.conf is a symlink managed by systemd-resolved.
@@ -1626,7 +1780,10 @@ install_sshd() {
sleep 3
done
elif command -v apk >/dev/null 2>&1; then
run_timeout 60 apk add --no-cache openssh-server openssh-client shadow iproute2 procps net-tools && return 0
for i in 1 2 3; do
run_timeout 120 apk add --no-cache openssh-server openssh-client shadow iproute2 procps net-tools && return 0
sleep 3
done
elif command -v pacman >/dev/null 2>&1; then
run_timeout 45 pacman -Syu --noconfirm >/dev/null 2>&1 || true
run_timeout 90 pacman -S --noconfirm openssh shadow iproute2 procps-ng net-tools && return 0
@@ -1685,11 +1842,6 @@ set_sshd_option KbdInteractiveAuthentication no
set_sshd_option ChallengeResponseAuthentication no
set_sshd_option UsePAM no
if [ -n "$ROOT_PASSWORD" ]; then
printf '%s:%s\n' root "$ROOT_PASSWORD" | chpasswd || exit 31
passwd -u root >/dev/null 2>&1 || true
fi
if command -v rc-update >/dev/null 2>&1; then
rc-update add sshd default >/dev/null 2>&1 || true
fi
@@ -1747,14 +1899,17 @@ pgrep -x sshd >/dev/null 2>&1 || exit 33
}
// 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)
if c == nil {
return "", fmt.Errorf("container not found: %d", id)
}
lxcName := c.LxcName()
newPassword := generateRandomString(16)
newPassword := strings.TrimSpace(password)
if newPassword == "" {
newPassword = generateRandomString(16)
}
if c.Status == "running" {
c.SSHPassword = newPassword
@@ -1767,13 +1922,11 @@ func (m *Manager) ResetSSHPassword(id int) (string, error) {
return "", err
}
rootfsPath := filepath.Join(m.LxcPath, lxcName, "rootfs")
if err := m.preconfigureSSH(rootfsPath, newPassword, c.Template); err != nil {
if err := m.preconfigureSSH(rootfsPath, c.Template); err != nil {
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)))
output, err := cmd.CombinedOutput()
if err != nil {
return "", fmt.Errorf("failed to set password: %v, output: %s", err, string(output))
if err := m.setRootfsPassword(rootfsPath, newPassword); err != nil {
return "", fmt.Errorf("failed to set password: %v", err)
}
c.SSHPassword = newPassword
config.SaveConfig()
@@ -1782,22 +1935,133 @@ func (m *Manager) ResetSSHPassword(id int) (string, error) {
return newPassword, nil
}
func (m *Manager) rootfsCommand(rootfsPath string, args ...string) *exec.Cmd {
marker := filepath.Join(rootfsPath, ".clicd-unprivileged-shifted")
func (m *Manager) rootfsCommand(rootfsPath string, args ...string) (*exec.Cmd, error) {
cleanRootfsPath, err := m.safeRootfsPath(rootfsPath)
if err != nil {
return nil, err
}
safeArgs, err := safeRootfsCommandArgs(args)
if err != nil {
return nil, err
}
marker := filepath.Join(cleanRootfsPath, ".clicd-unprivileged-shifted")
if _, err := os.Stat(marker); err == nil {
uidBase, gidBase, mapErr := unprivilegedIDMap()
if mapErr == nil {
cmdArgs := []string{
"-m", fmt.Sprintf("u:0:%d:65536", uidBase),
"-m", fmt.Sprintf("g:0:%d:65536", gidBase),
"--", "chroot", rootfsPath,
"--", "chroot", "--", cleanRootfsPath,
}
cmdArgs = append(cmdArgs, args...)
return exec.Command("lxc-usernsexec", cmdArgs...)
cmdArgs = append(cmdArgs, safeArgs...)
return exec.Command("lxc-usernsexec", cmdArgs...), nil
}
}
cmdArgs := append([]string{rootfsPath}, args...)
return exec.Command("chroot", cmdArgs...)
cmdArgs := append([]string{"--", cleanRootfsPath}, safeArgs...)
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) setRootfsPassword(rootfsPath, password string) error {
if err := validateRootPassword(password); err != nil {
return err
}
cmd, err := m.rootfsCommand(rootfsPath, "chpasswd")
if err != nil {
return err
}
cmd.Stdin = strings.NewReader(rootPasswordInput(password))
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("%v, output: %s", err, string(output))
}
return nil
}
func safeRootfsCommandArgs(args []string) ([]string, error) {
if len(args) == 0 {
return nil, fmt.Errorf("empty rootfs command")
}
allowed := map[string]bool{
"chpasswd": true,
"rc-update": true,
"sh": true,
"systemctl": true,
}
if !allowed[args[0]] || strings.HasPrefix(args[0], "-") || strings.Contains(args[0], "/") {
return nil, fmt.Errorf("rootfs command is not allowed: %s", args[0])
}
for _, arg := range args {
if strings.ContainsRune(arg, '\x00') {
return nil, fmt.Errorf("rootfs command argument contains NUL byte")
}
}
if args[0] == "sh" {
if len(args) != 3 || args[1] != "-c" {
return nil, fmt.Errorf("unsupported rootfs shell invocation")
}
if !isCLICDManagedRootfsScript(args[2]) {
return nil, fmt.Errorf("refusing unmanaged rootfs shell script")
}
}
return append([]string(nil), args...), nil
}
func isCLICDManagedRootfsScript(script string) bool {
return strings.Contains(script, "99-clicd.conf") &&
strings.Contains(script, "install_sshd") &&
!strings.Contains(script, "ROOT_PASSWORD") &&
!strings.Contains(script, "chpasswd")
}
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)
}
parts := strings.Split(rel, string(os.PathSeparator))
if len(parts) != 2 || parts[1] != "rootfs" {
return "", fmt.Errorf("refusing nested or malformed rootfs path: %s", cleanRootfsPath)
}
if strings.HasPrefix(parts[0], "-") || !regexp.MustCompile(`^[A-Za-z0-9_.-]+$`).MatchString(parts[0]) {
return "", fmt.Errorf("refusing unsafe container directory name: %s", parts[0])
}
return cleanRootfsPath, nil
}
func (m *Manager) cleanupContainerStorage(lxcName string) error {
@@ -1921,6 +2185,9 @@ func (m *Manager) GetContainerIP(lxcName string) (string, error) {
func (m *Manager) ListContainers() ([]config.Container, error) {
containers := config.AppConfig.Containers
for i := range containers {
if containers[i].IsKVM() {
continue
}
status, err := m.GetContainerStatus(containers[i].LxcName())
if err == nil {
containers[i].Status = status
@@ -1996,6 +2263,7 @@ func (m *Manager) ImportExistingClicdContainers() ([]config.Container, error) {
UUID: config.NewContainerUUID(),
Name: name,
LXCName: lxcName,
Virtualization: config.VirtualizationLXC,
Template: "imported",
VCPU: 1,
RAMMB: 512,
@@ -2035,6 +2303,85 @@ func (m *Manager) ImportExistingClicdContainers() ([]config.Container, error) {
return imported, nil
}
func (m *Manager) replaceRootfsFromTemplate(lxcName string, tmpl *Template) error {
if tmpl == nil {
return fmt.Errorf("template is nil")
}
tmpName := fmt.Sprintf("clicd-reinstall-%s-%s", lxcName, generateRandomString(8))
tmpDir := filepath.Join(m.LxcPath, tmpName)
if err := os.RemoveAll(tmpDir); err != nil {
return fmt.Errorf("failed to clean temporary reinstall directory: %v", err)
}
defer m.cleanupTemporaryContainer(tmpName)
args := []string{
"-n", tmpName,
"-t", "download",
"--",
"-d", tmpl.Distro,
"-r", tmpl.Release,
"-a", tmpl.Arch,
}
if tmpl.Variant != "" {
args = append(args, "--variant", tmpl.Variant)
}
output, err := exec.Command("lxc-create", args...).CombinedOutput()
if err != nil {
return fmt.Errorf("failed to download replacement rootfs: %v, output: %s", err, string(output))
}
tmpRootfs := filepath.Join(tmpDir, "rootfs")
if !rootfsHasInit(tmpRootfs) {
return fmt.Errorf("downloaded replacement rootfs is invalid: init not found")
}
rootfsPath := filepath.Join(m.LxcPath, lxcName, "rootfs")
if err := m.ensureDiskImageMounted(lxcName); err != nil {
return err
}
m.unmountRootfsChildMounts(rootfsPath)
if err := os.MkdirAll(rootfsPath, 0755); err != nil {
return err
}
if err := removeDirectoryContents(rootfsPath); err != nil {
return fmt.Errorf("failed to clear old rootfs: %v", err)
}
if err := copyRootfsContents(tmpRootfs, rootfsPath); err != nil {
return err
}
if !rootfsHasInit(rootfsPath) {
return fmt.Errorf("replacement rootfs copy failed: init not found")
}
return nil
}
func (m *Manager) cleanupTemporaryContainer(lxcName string) {
exec.Command("lxc-stop", "-n", lxcName, "-k").Run()
exec.Command("lxc-destroy", "-n", lxcName, "-f").Run()
os.RemoveAll(filepath.Join(m.LxcPath, lxcName))
}
func removeDirectoryContents(dir string) error {
entries, err := os.ReadDir(dir)
if err != nil {
return err
}
for _, entry := range entries {
if err := os.RemoveAll(filepath.Join(dir, entry.Name())); err != nil {
return err
}
}
return nil
}
func copyRootfsContents(src, dst string) error {
output, err := exec.Command("cp", "-a", src+string(os.PathSeparator)+".", dst+string(os.PathSeparator)).CombinedOutput()
if err != nil {
return fmt.Errorf("failed to copy replacement rootfs: %v, output: %s", err, string(output))
}
return nil
}
// ReinstallContainer reinstalls the container OS
func (m *Manager) ReinstallContainer(id int, templateID string) error {
c := config.FindContainer(id)
@@ -2058,26 +2405,10 @@ func (m *Manager) ReinstallContainer(id int, templateID string) error {
// Clean port mappings temporarily
m.CleanPortMappings(id)
// Destroy old LXC but keep config
exec.Command("lxc-stop", "-n", lxcName, "-k").Run()
exec.Command("lxc-destroy", "-n", lxcName, "-f").Run()
rootfs := filepath.Join(m.LxcPath, lxcName, "rootfs")
exec.Command("umount", "-R", "-l", rootfs).Run()
os.RemoveAll(rootfs)
os.Remove(filepath.Join(m.LxcPath, lxcName, "rootfs.img"))
// Create new container with same LXC name (preserves ID)
cmd := exec.Command("lxc-create",
"-n", lxcName,
"-t", "download",
"--",
"-d", tmpl.Distro,
"-r", tmpl.Release,
"-a", tmpl.Arch,
)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("lxc-create failed: %v, output: %s", err, string(output))
// Download the new OS into a temporary container, then replace only the
// existing rootfs. The target container directory and config are preserved.
if err := m.replaceRootfsFromTemplate(lxcName, tmpl); err != nil {
return err
}
if err := m.applyDiskLimit(lxcName, c.DiskGB); err != nil {
@@ -2117,15 +2448,15 @@ func (m *Manager) ReinstallContainer(id int, templateID string) error {
if c.SSHPassword == "" {
c.SSHPassword = generateRandomString(16)
}
if err := m.preconfigureSSH(rootfsPath, c.SSHPassword, templateID); err != nil {
if err := m.preconfigureSSH(rootfsPath, templateID); err != nil {
fmt.Printf("Warning: failed to pre-configure SSH in %s after reinstall: %v\n", lxcName, err)
}
if err := m.shiftRootfsForUnprivileged(lxcName); err != nil {
return err
}
setCmd := m.rootfsCommand(rootfsPath,
"sh", "-c", fmt.Sprintf("printf '%%s:%%s\\n' root %s | chpasswd", shellQuote(c.SSHPassword)))
setCmd.Run()
if err := m.setRootfsPassword(rootfsPath, c.SSHPassword); err != nil {
fmt.Printf("Warning: failed to set root password in %s after reinstall: %v\n", lxcName, err)
}
// Update template and keep everything else the same
c.Template = templateID
@@ -2193,6 +2524,11 @@ func (m *Manager) GetResourceUsage(id int) (map[string]interface{}, error) {
usage := make(map[string]interface{})
// Read raw values
load1, load5, load15 := m.getContainerLoadAvg(lxcName)
usage["load1"] = load1
usage["load5"] = load5
usage["load15"] = load15
memUsage := readIntCommand(fmt.Sprintf(
"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 || "+
@@ -2240,15 +2576,62 @@ func (m *Manager) GetResourceUsage(id int) (map[string]interface{}, error) {
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) {
pid := m.getContainerInitPID(lxcName)
if pid == "" {
return 0, 0
}
dir := fmt.Sprintf("/proc/%s/net", pid)
rx := readIntCommand(fmt.Sprintf("cat %s/dev 2>/dev/null | awk '{rx+=$2; tx+=$10} END {print rx}' || echo 0", shellQuote(dir)))
tx := readIntCommand(fmt.Sprintf("cat %s/dev 2>/dev/null | awk '{rx+=$2; tx+=$10} END {print tx}' || echo 0", shellQuote(dir)))
return uint64(rx), uint64(tx)
return readProcNetDev(fmt.Sprintf("/proc/%s/net/dev", pid))
}
// readProcNetDev parses /proc/PID/net/dev directly (no shell/awk fork).
func readProcNetDev(path string) (uint64, uint64) {
data, err := os.ReadFile(path)
if err != nil {
return 0, 0
}
var rx, tx uint64
scanner := bufio.NewScanner(strings.NewReader(string(data)))
for scanner.Scan() {
line := scanner.Text()
// Skip header lines
if strings.Contains(line, "|") || strings.Contains(line, "face") || strings.TrimSpace(line) == "" {
continue
}
fields := strings.Fields(line)
// Fields: face | rx_bytes rx_packets rx_errs rx_drop rx_fifo rx_frame rx_compressed rx_multicast | tx_bytes tx_packets tx_errs tx_drop tx_fifo tx_colls tx_carrier tx_compressed
// Skip loopback (face starts with "lo")
if len(fields) < 10 {
continue
}
if strings.HasPrefix(fields[0], "lo") {
continue
}
r, _ := strconv.ParseUint(fields[1], 10, 64)
t, _ := strconv.ParseUint(fields[9], 10, 64)
rx += r
tx += t
}
return rx, tx
}
func (m *Manager) getContainerDiskIOBytes(lxcName string) (uint64, uint64) {
@@ -2256,10 +2639,22 @@ func (m *Manager) getContainerDiskIOBytes(lxcName string) (uint64, uint64) {
if pid == "" {
return 0, 0
}
// /proc/PID/io format: "field_name: value" per line
// Fields: rchar, wchar, syscr, syscw, read_bytes, write_bytes, cancelled_write_bytes
readBytes := uint64(readIntCommand(fmt.Sprintf("awk '/^read_bytes:/ {print $2}' /proc/%s/io 2>/dev/null || echo 0", pid)))
writeBytes := uint64(readIntCommand(fmt.Sprintf("awk '/^write_bytes:/ {print $2}' /proc/%s/io 2>/dev/null || echo 0", pid)))
data, err := os.ReadFile(fmt.Sprintf("/proc/%s/io", pid))
if err != nil {
return 0, 0
}
var readBytes, writeBytes uint64
scanner := bufio.NewScanner(strings.NewReader(string(data)))
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, "read_bytes:") {
val := strings.TrimSpace(strings.TrimPrefix(line, "read_bytes:"))
readBytes, _ = strconv.ParseUint(val, 10, 64)
} else if strings.HasPrefix(line, "write_bytes:") {
val := strings.TrimSpace(strings.TrimPrefix(line, "write_bytes:"))
writeBytes, _ = strconv.ParseUint(val, 10, 64)
}
}
return readBytes, writeBytes
}
@@ -2272,6 +2667,73 @@ func (m *Manager) getContainerInitPID(lxcName string) string {
return strings.TrimSpace(string(out))
}
// readCgroupFile tries each path template in order, reads the file directly (no shell),
// and returns the first valid int64 value.
func readCgroupFile(name string, paths ...string) int64 {
for _, tmpl := range paths {
data, err := os.ReadFile(fmt.Sprintf(tmpl, name))
if err != nil {
continue
}
val, err := strconv.ParseInt(strings.TrimSpace(string(data)), 10, 64)
if err == nil && val > 0 {
return val
}
}
return 0
}
// readCgroupCPUUsec tries each path template, reads cpu.stat, and extracts usage_usec.
func readCgroupCPUUsec(name string, paths ...string) uint64 {
for _, tmpl := range paths {
data, err := os.ReadFile(fmt.Sprintf(tmpl, name))
if err != nil {
continue
}
scanner := bufio.NewScanner(strings.NewReader(string(data)))
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, "usage_usec ") {
val, err := strconv.ParseUint(strings.TrimSpace(strings.TrimPrefix(line, "usage_usec")), 10, 64)
if err == nil {
return val
}
}
}
}
return 0
}
// getNetworkBytesForPID reads /proc/PID/net/dev for a given PID (no lxc-info needed).
func getNetworkBytesForPID(pid string) (uint64, uint64) {
if pid == "" {
return 0, 0
}
return readProcNetDev(fmt.Sprintf("/proc/%s/net/dev", pid))
}
// getDiskIOBytesForPID reads /proc/PID/io for a given PID (no lxc-info needed).
func getDiskIOBytesForPID(pid string) (uint64, uint64) {
if pid == "" {
return 0, 0
}
data, err := os.ReadFile(fmt.Sprintf("/proc/%s/io", pid))
if err != nil {
return 0, 0
}
var readBytes, writeBytes uint64
scanner := bufio.NewScanner(strings.NewReader(string(data)))
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, "read_bytes:") {
readBytes, _ = strconv.ParseUint(strings.TrimSpace(strings.TrimPrefix(line, "read_bytes:")), 10, 64)
} else if strings.HasPrefix(line, "write_bytes:") {
writeBytes, _ = strconv.ParseUint(strings.TrimSpace(strings.TrimPrefix(line, "write_bytes:")), 10, 64)
}
}
return readBytes, writeBytes
}
// getContainerUptimeSeconds returns how long the container has been running (in seconds).
func (m *Manager) getContainerUptimeSeconds(lxcName string) float64 {
pid := m.getContainerInitPID(lxcName)
@@ -2399,6 +2861,7 @@ func (m *Manager) AccumulateTraffic() {
lastTrafficSnapshotMu.Lock()
defer lastTrafficSnapshotMu.Unlock()
changed := false
for i := range config.AppConfig.Containers {
c := &config.AppConfig.Containers[i]
if c.Status != "running" {
@@ -2406,23 +2869,34 @@ func (m *Manager) AccumulateTraffic() {
delete(lastTrafficSnapshot, c.LxcName())
continue
}
if c.IsKVM() {
continue
}
// Reset if new month
if c.TrafficResetDate != currentMonth {
c.TrafficUsedRX = 0
c.TrafficUsedTX = 0
c.TrafficResetDate = currentMonth
delete(lastTrafficSnapshot, c.LxcName())
changed = true
}
rx, tx := m.getContainerNetworkBytes(c.LxcName())
prev, exists := lastTrafficSnapshot[c.LxcName()]
// Only add the DELTA (increment since last snapshot)
if exists && rx >= prev.RXBytes && tx >= prev.TXBytes {
c.TrafficUsedRX += int64(rx - prev.RXBytes)
c.TrafficUsedTX += int64(tx - prev.TXBytes)
deltaRX := int64(rx - prev.RXBytes)
deltaTX := int64(tx - prev.TXBytes)
if deltaRX > 0 || deltaTX > 0 {
c.TrafficUsedRX += deltaRX
c.TrafficUsedTX += deltaTX
changed = true
}
}
lastTrafficSnapshot[c.LxcName()] = trafficSample{RXBytes: rx, TXBytes: tx}
}
config.SaveConfig()
if changed {
config.SaveConfig()
}
}
// GetTrafficInfo returns traffic usage info for a container
+90
View File
@@ -0,0 +1,90 @@
package lxc
import (
"os"
"path/filepath"
"reflect"
"strings"
"testing"
)
func TestRootfsCommandAddsSeparatorForAllowedCommand(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, "chpasswd")
if err != nil {
t.Fatalf("rootfsCommand returned error: %v", err)
}
want := []string{"chroot", "--", rootfs, "chpasswd"}
if !reflect.DeepEqual(cmd.Args, want) {
t.Fatalf("cmd.Args = %#v, want %#v", cmd.Args, want)
}
}
func TestRootfsCommandRejectsUnmanagedCommand(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}
if _, err := m.rootfsCommand(rootfs, "true"); err == nil {
t.Fatal("rootfsCommand allowed unmanaged command")
}
}
func TestRootfsCommandRejectsLeadingDashContainerName(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}
if _, err := m.rootfsCommand(rootfs, "chpasswd"); err == nil {
t.Fatal("rootfsCommand allowed leading-dash container name")
}
}
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, "chpasswd"); 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")
}
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)
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)
}
if exec.Command("iptables", "-t", "nat", "-C", "POSTROUTING", "-s", "10.0.3.0/24", "-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()
if exec.Command("iptables", "-t", "nat", "-C", "POSTROUTING", "-s", subnet, "-o", "eth+", "-j", "MASQUERADE").Run() != nil {
exec.Command("iptables", "-t", "nat", "-I", "POSTROUTING", "1", "-s", subnet, "-o", "eth+", "-j", "MASQUERADE").Run()
}
return nil
@@ -50,18 +56,25 @@ func (m *Manager) ApplyPortMappings(id int) error {
func clicdTag(id int) string { return "c" + strconv.Itoa(id) }
// EnsureForwardRules makes sure iptables FORWARD chain allows LXC bridge traffic
func EnsureForwardRules() {
// EnsureForwardRules makes sure iptables FORWARD chain allows bridge traffic.
func EnsureForwardRules(bridge string) {
if bridge == "" {
bridge = "lxcbr0"
}
rules := [][]string{
{"-A", "FORWARD", "-i", "lxcbr0", "-j", "ACCEPT"},
{"-A", "FORWARD", "-o", "lxcbr0", "-j", "ACCEPT"},
{"-A", "FORWARD", "-i", "lxcbr0", "-o", "lxcbr0", "-j", "ACCEPT"},
{"-i", bridge, "-j", "ACCEPT"},
{"-o", bridge, "-j", "ACCEPT"},
{"-i", bridge, "-o", bridge, "-j", "ACCEPT"},
}
for _, args := range rules {
checkArgs := append([]string{"-C", "FORWARD"}, args[2:]...)
if exec.Command("iptables", checkArgs...).Run() != nil {
exec.Command("iptables", args...).Run()
for {
deleteArgs := append([]string{"-D", "FORWARD"}, args...)
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 {
pm.HostPort = pm.ContainerPort
}
// Check current container's own mappings
for i, existing := range c.PortMappings {
if i == skipIndex {
continue
}
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
@@ -177,10 +202,20 @@ func allocateDefaultEqualPorts(c *config.Container, count int) []int {
return nil
}
used := map[int]bool{}
// Mark current container's ports
for _, pm := range c.PortMappings {
used[pm.HostPort] = 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)
next := 20000
for len(ports) < count {
+3 -2
View File
@@ -45,7 +45,8 @@ func (m *Manager) CreateSnapshot(id int, createdBy string, scheduled bool, rotat
now := time.Now()
snapshotID := fmt.Sprintf("snap-%d-%s", id, now.Format("20060102150405-000000000"))
snapshotDir := filepath.Join(snapshotBaseDir(), lxcName, snapshotID)
// Use container ID instead of lxcName to avoid collision when containers are recreated
snapshotDir := filepath.Join(snapshotBaseDir(), strconv.Itoa(id), snapshotID)
if err := safePathUnder(snapshotDir, snapshotBaseDir()); err != nil {
return config.Snapshot{}, err
}
@@ -206,7 +207,7 @@ func (m *Manager) runDueSnapshotSchedules() {
now := time.Now()
containers := append([]config.Container(nil), config.AppConfig.Containers...)
for _, c := range containers {
if !c.SnapshotScheduleEnabled {
if c.IsKVM() || !c.SnapshotScheduleEnabled {
continue
}
nextRun, err := time.Parse(time.RFC3339, c.SnapshotScheduleNextRun)
+82 -18
View File
@@ -1,17 +1,17 @@
package server
import (
"crypto/tls"
"fmt"
"log"
"net"
"net/http"
"net/url"
"os"
"strings"
"time"
"clicd/internal/api"
"clicd/internal/config"
"clicd/internal/lxc"
)
// webFS holds embedded frontend files
@@ -25,7 +25,7 @@ func corsMiddleware(next http.HandlerFunc) http.HandlerFunc {
w.Header().Set("Vary", "Origin")
w.Header().Set("Access-Control-Allow-Credentials", "true")
}
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-API-Key")
if r.Method == http.MethodOptions {
@@ -77,22 +77,23 @@ func setupRoutes(mux *http.ServeMux) {
mux.HandleFunc("/api/change-password", corsMiddleware(api.AdminMiddleware(api.HandleAdminPasswordChange)))
mux.HandleFunc("/api/change-username", corsMiddleware(api.AdminMiddleware(api.HandleAdminUsernameChange)))
mux.HandleFunc("/api/login-logs", corsMiddleware(api.AdminMiddleware(api.HandleLoginLogs)))
mux.HandleFunc("/api/ssl", corsMiddleware(api.AdminMiddleware(api.HandleSSLSettings)))
mux.HandleFunc("/api/containers", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleContainers))))
mux.HandleFunc("/api/containers/list", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleContainerListAlias))))
mux.HandleFunc("/api/containers/", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleSingleContainer))))
mux.HandleFunc("/api/templates", corsMiddleware(api.AuthMiddleware(api.HandleTemplates)))
mux.HandleFunc("/api/images", corsMiddleware(api.AdminMiddleware(api.HandleImages)))
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/toggle", corsMiddleware(api.AdminMiddleware(api.HandleImageToggle)))
mux.HandleFunc("/api/images/enabled", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleEnabledImages))))
mux.HandleFunc("/api/dashboard", corsMiddleware(api.AdminMiddleware(api.HandleDashboard)))
mux.HandleFunc("/api/host-info", corsMiddleware(api.AdminMiddleware(api.HandleHostInfo)))
mux.HandleFunc("/api/host-report", corsMiddleware(api.AdminMiddleware(api.HandleHostReport)))
mux.HandleFunc("/api/snapshots", corsMiddleware(api.AdminMiddleware(api.HandleSnapshots)))
mux.HandleFunc("/api/routing", corsMiddleware(api.AdminMiddleware(api.HandleRouting)))
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.AdminMiddleware(api.HandleTaskDelete))))
mux.HandleFunc("/api/batch-create", corsMiddleware(api.AdminMiddleware(api.HandleBatchCreate)))
@@ -100,18 +101,67 @@ func setupRoutes(mux *http.ServeMux) {
mux.HandleFunc("/api/sub-user/create", corsMiddleware(api.AdminMiddleware(api.HandleSubUserCreate)))
mux.HandleFunc("/api/sub-user/login", corsMiddleware(api.HandleSubUserLogin))
mux.HandleFunc("/api/sub-user/access", corsMiddleware(api.HandleSubUserAccessCode))
mux.HandleFunc("/api/sub-users", corsMiddleware(api.AdminMiddleware(api.HandleSubUserList)))
mux.HandleFunc("/api/sub-users/", corsMiddleware(api.AdminMiddleware(api.HandleSubUserAction)))
mux.HandleFunc("/api/audit-logs", corsMiddleware(api.AdminMiddleware(api.HandleAuditLogs)))
mux.HandleFunc("/api/security/alerts", corsMiddleware(api.AdminMiddleware(api.HandleSecurityAlerts)))
mux.HandleFunc("/api/security/check", corsMiddleware(api.AdminMiddleware(api.HandleSecurityCheck)))
mux.HandleFunc("/api/security/logs", corsMiddleware(api.AdminMiddleware(api.HandleSecurityLogs)))
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", api.HandleWebSSH) // WebSocket
mux.HandleFunc("/api/vnc-ticket", corsMiddleware(api.AuthMiddleware(api.HandleVNCTicket)))
mux.HandleFunc("/api/vnc", api.HandleVNCProxy) // WebSocket
// API Key management
mux.HandleFunc("/api/api-keys", corsMiddleware(api.AdminMiddleware(api.HandleApiKeys)))
mux.HandleFunc("/api/api-keys/", corsMiddleware(api.AdminMiddleware(api.HandleApiKeyDelete)))
// Versioned external API routes
mux.HandleFunc("/api/v1/dashboard", corsMiddleware(api.AuthMiddleware(api.HandleDashboard)))
mux.HandleFunc("/api/v1/containers", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleContainers))))
mux.HandleFunc("/api/v1/containers/list", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleContainerListAlias))))
mux.HandleFunc("/api/v1/containers/", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleSingleContainer))))
mux.HandleFunc("/api/v1/templates", corsMiddleware(api.AuthMiddleware(api.HandleTemplates)))
mux.HandleFunc("/api/v1/images", corsMiddleware(api.AuthMiddleware(api.HandleImages)))
mux.HandleFunc("/api/v1/images/download", corsMiddleware(api.AuthMiddleware(api.HandleImageDownload)))
mux.HandleFunc("/api/v1/images/cancel", corsMiddleware(api.AuthMiddleware(api.HandleImageCancel)))
mux.HandleFunc("/api/v1/images/delete", corsMiddleware(api.AuthMiddleware(api.HandleImageDelete)))
mux.HandleFunc("/api/v1/images/toggle", corsMiddleware(api.AuthMiddleware(api.HandleImageToggle)))
mux.HandleFunc("/api/v1/images/enabled", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleEnabledImages))))
mux.HandleFunc("/api/v1/host-info", corsMiddleware(api.AuthMiddleware(api.HandleHostInfo)))
mux.HandleFunc("/api/v1/host-report", corsMiddleware(api.AuthMiddleware(api.HandleHostReport)))
mux.HandleFunc("/api/v1/snapshots", corsMiddleware(api.AuthMiddleware(api.ScopeMiddleware("snapshot:read", api.HandleSnapshots))))
mux.HandleFunc("/api/v1/routing", corsMiddleware(api.AuthMiddleware(api.HandleRouting)))
mux.HandleFunc("/api/v1/ipv6/status", corsMiddleware(api.AuthMiddleware(api.HandleIPv6Status)))
mux.HandleFunc("/api/v1/tasks", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleTasks))))
mux.HandleFunc("/api/v1/tasks/", corsMiddleware(api.AuthMiddleware(api.HandleTaskDelete)))
mux.HandleFunc("/api/v1/batch-create", corsMiddleware(api.AuthMiddleware(api.HandleBatchCreate)))
mux.HandleFunc("/api/v1/batch-action", corsMiddleware(api.AuthMiddleware(api.HandleBatchAction)))
mux.HandleFunc("/api/v1/sub-user/create", corsMiddleware(api.AuthMiddleware(api.HandleSubUserCreate)))
mux.HandleFunc("/api/v1/sub-users", corsMiddleware(api.AuthMiddleware(api.HandleSubUserList)))
mux.HandleFunc("/api/v1/sub-users/", corsMiddleware(api.AuthMiddleware(api.HandleSubUserAction)))
mux.HandleFunc("/api/v1/audit-logs", corsMiddleware(api.AuthMiddleware(api.HandleAuditLogs)))
mux.HandleFunc("/api/v1/login-logs", corsMiddleware(api.AuthMiddleware(api.HandleLoginLogs)))
mux.HandleFunc("/api/v1/ssl", corsMiddleware(api.AdminMiddleware(api.HandleSSLSettings)))
mux.HandleFunc("/api/v1/security/alerts", corsMiddleware(api.AuthMiddleware(api.ScopeMiddleware("security:read", api.HandleSecurityAlerts))))
mux.HandleFunc("/api/v1/security/check", corsMiddleware(api.AuthMiddleware(api.ScopeMiddleware("security:check", api.HandleSecurityCheck))))
mux.HandleFunc("/api/v1/security/logs", corsMiddleware(api.AuthMiddleware(api.ScopeMiddleware("security:read", api.HandleSecurityLogs))))
mux.HandleFunc("/api/v1/security/summary", corsMiddleware(api.AuthMiddleware(api.ScopeMiddleware("security:read", api.HandleContainerSecuritySummary))))
mux.HandleFunc("/api/v1/security/settings", corsMiddleware(api.AuthMiddleware(api.HandleSecuritySettings)))
mux.HandleFunc("/api/v1/ssh-ticket", corsMiddleware(api.AuthMiddleware(api.HandleWebSSHTicket)))
mux.HandleFunc("/api/v1/vnc-ticket", corsMiddleware(api.AuthMiddleware(api.HandleVNCTicket)))
mux.HandleFunc("/api/v1/api-keys", corsMiddleware(api.AuthMiddleware(api.HandleApiKeys)))
mux.HandleFunc("/api/v1/api-keys/", corsMiddleware(api.AuthMiddleware(api.HandleApiKeyDelete)))
mux.HandleFunc("/api/v1/swap", corsMiddleware(api.AuthMiddleware(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
api.HandleSwapInfo(w, r)
return
}
api.HandleSwapManage(w, r)
})))
// Version (public)
mux.HandleFunc("/api/version", corsMiddleware(api.HandleVersion))
@@ -149,7 +199,6 @@ func setupRoutes(mux *http.ServeMux) {
func Run() error {
// Use embedded frontend files
webFS = GetEmbeddedFS()
startExpiryMonitor()
mux := http.NewServeMux()
setupRoutes(mux)
@@ -163,18 +212,33 @@ func Run() error {
Handler: mux,
}
if sslEnabled() {
server.TLSConfig = &tls.Config{
MinVersion: tls.VersionTLS12,
GetCertificate: func(*tls.ClientHelloInfo) (*tls.Certificate, error) {
cert, err := tls.LoadX509KeyPair(config.AppConfig.SSL.CertPath, config.AppConfig.SSL.KeyPath)
return &cert, err
},
}
log.Printf("CLICD Web Server SSL enabled on https://0.0.0.0:%d", config.AppConfig.Port)
return server.ListenAndServeTLS("", "")
}
return server.ListenAndServe()
}
func startExpiryMonitor() {
manager := lxc.NewManager()
go func() {
manager.StopExpiredContainers(time.Now())
ticker := time.NewTicker(time.Minute)
defer ticker.Stop()
for now := range ticker.C {
manager.StopExpiredContainers(now)
}
}()
func sslEnabled() bool {
ssl := config.AppConfig.SSL
if !ssl.Enabled || ssl.CertPath == "" || ssl.KeyPath == "" {
return false
}
if _, err := os.Stat(ssl.CertPath); err != nil {
log.Printf("SSL certificate is not readable, falling back to HTTP: %v", err)
return false
}
if _, err := os.Stat(ssl.KeyPath); err != nil {
log.Printf("SSL private key is not readable, falling back to HTTP: %v", err)
return false
}
return true
}
+1
View File
@@ -0,0 +1 @@

+1 -3
View File
@@ -1,7 +1,7 @@
package version
var (
Version = "1.0.7"
Version = "1.1.7"
Repo = "MengMengCode/CLICD"
)
@@ -11,5 +11,3 @@ func Current() string {
}
return Version
}
+14 -5
View File
@@ -9,6 +9,7 @@ import (
"clicd/internal/api"
"clicd/internal/cli"
"clicd/internal/config"
"clicd/internal/kvm"
"clicd/internal/lxc"
"clicd/internal/server"
@@ -49,19 +50,27 @@ func main() {
// Start security scanner
api.InitScanner()
api.StartSSLRenewalMonitor()
// Ensure iptables FORWARD rules allow LXC traffic
lxc.EnsureForwardRules()
// Ensure iptables FORWARD rules allow managed bridge traffic.
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()
kvmManager := kvm.NewManager()
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()
kvmManager.StartUsageMonitor()
kvmManager.StartNetworkSyncMonitor()
kvmManager.StartIPv6Guard()
// Start scheduled snapshot scanner.
// Start scheduled snapshot scanners.
manager.StartSnapshotScheduler()
kvmManager.StartSnapshotScheduler()
// Clean up stale container configs (LXC dir was deleted but config remains)
config.CleanStaleContainers()
+4
View File
@@ -0,0 +1,4 @@
node_modules/
.vitepress/cache/
.vitepress/dist/
.vitepress/.temp/
+74
View File
@@ -0,0 +1,74 @@
import { defineConfig } from 'vitepress'
export default defineConfig({
title: 'CLICD',
description: '面向 LXC/KVM 的轻量虚拟化管理面板文档',
lang: 'zh-CN',
base: process.env.VITEPRESS_BASE || '/',
cleanUrls: true,
ignoreDeadLinks: true,
head: [
['link', { rel: 'icon', href: '/favicon.svg' }],
],
themeConfig: {
logo: '/favicon.svg',
search: {
provider: 'local',
},
nav: [
{ text: '指南', link: '/guide/introduction' },
{ text: '功能', link: '/features/dashboard' },
{ text: '运维', link: '/operations/deployment' },
{ text: '开发', link: '/developer/architecture' },
],
sidebar: [
{
text: '开始',
items: [
{ text: '项目介绍', link: '/guide/introduction' },
{ text: '安装', link: '/guide/installation' },
{ text: '升级', link: '/guide/upgrade' },
{ text: '快速上手', link: '/guide/quick-start' },
{ text: '配置说明', link: '/guide/configuration' },
],
},
{
text: '功能',
items: [
{ text: '控制面板', link: '/features/dashboard' },
{ text: '容器管理', link: '/features/containers' },
{ text: '镜像管理', link: '/features/images' },
{ text: '网络与路由', link: '/features/networking' },
{ text: '快照管理', link: '/features/snapshots' },
{ text: '安全告警', link: '/features/security' },
{ text: '子用户', link: '/features/sub-users' },
{ text: 'API 集成', link: '/features/api' },
{ text: '主机报告', link: '/features/host-report' },
],
},
{
text: '运维',
items: [
{ text: '部署建议', link: '/operations/deployment' },
{ text: '故障排查', link: '/operations/troubleshooting' },
{ text: '常见问题', link: '/operations/faq' },
],
},
{
text: '开发',
items: [
{ text: '系统架构', link: '/developer/architecture' },
{ text: '本地构建', link: '/developer/build' },
{ text: '发布流程', link: '/developer/release' },
],
},
],
socialLinks: [
{ icon: 'github', link: 'https://github.com/MengMengCode/CLICD' },
],
footer: {
message: 'CLICD 文档面向部署、使用、运维和二次开发场景。',
copyright: 'Copyright © CLICD contributors',
},
},
})
+26
View File
@@ -0,0 +1,26 @@
:root {
--vp-c-brand-1: #0284c7;
--vp-c-brand-2: #0ea5e9;
--vp-c-brand-3: #7dd3fc;
--vp-c-brand-soft: rgba(14, 165, 233, 0.14);
--vp-home-hero-name-color: #0369a1;
--vp-home-hero-image-background-image: linear-gradient(135deg, #7dd3fc 0%, #38bdf8 46%, #86efac 100%);
--vp-home-hero-image-filter: blur(46px);
}
.dark {
--vp-c-brand-1: #7dd3fc;
--vp-c-brand-2: #38bdf8;
--vp-c-brand-3: #0ea5e9;
--vp-c-brand-soft: rgba(125, 211, 252, 0.16);
--vp-home-hero-name-color: #bae6fd;
}
.VPHomeHero .text {
max-width: 680px;
}
.vp-doc table {
display: table;
width: 100%;
}
+4
View File
@@ -0,0 +1,4 @@
import DefaultTheme from 'vitepress/theme'
import './custom.css'
export default DefaultTheme
+42
View File
@@ -0,0 +1,42 @@
# 系统架构
CLICD 由 Go 后端、React 前端和宿主机虚拟化能力组成。
## 后端
后端入口在 `backend/main.go`HTTP 服务路由集中在 `backend/internal/server/server.go`。主要模块:
- `internal/api`Web 面板和 `/api/v1` 的 HTTP 接口。
- `internal/config`:配置和 SQLite 存储。
- `internal/lxc`LXC 容器管理。
- `internal/kvm`KVM/libvirt 虚拟机管理。
- `internal/cli`:命令行管理入口。
- `internal/server`:静态前端嵌入和 HTTP 服务。
- `internal/version`:版本号。
## 前端
前端入口在 `frontend/src/main.tsx`,页面位于 `frontend/src/pages`,通用组件位于 `frontend/src/components`
主要页面:
- 控制面板:`Dashboard.tsx`
- 容器列表:`Containers.tsx`
- 容器详情:`ContainerDetail.tsx`
- 镜像管理:`ImageManagement.tsx`
- 安全告警:`Security.tsx`
- 快照管理:`Snapshots.tsx`
- 路由管理:`Routing.tsx`
- API 集成:`ApiIntegration.tsx`
- 主机报告:`HostReport.tsx`
- 子用户管理:`SubUserManagement.tsx`
## 前端嵌入
生产构建时,前端产物会放入 `backend/internal/server/web`,后端通过 Go embed 提供静态文件,并对非 API 路由返回 SPA 入口。
## 接口分层
- `/api/*`Web 面板和兼容接口。
- `/api/v1/*`:推荐给外部自动化系统使用的版本化接口。
- WebSSH 和 WebVNC 使用短期票据后建立 WebSocket 连接。
+42
View File
@@ -0,0 +1,42 @@
# 本地构建
## 前端构建
```bash
cd frontend
npm install
npm run build
```
构建输出位于 `frontend/dist`
## 后端构建
```bash
cd backend
go test ./...
go build -o ../build/clicd .
```
如果要打包嵌入式 Web 面板,需要先把前端构建产物同步到后端嵌入目录。
## 一键构建
项目根目录提供了构建脚本:
```bash
bash build.sh
```
该脚本用于串联前端构建、静态资源同步和 Go 二进制构建。
## 文档站构建
```bash
cd docs
npm install
npm run dev
npm run build
```
`npm run dev` 用于本地预览,`npm run build` 用于生成静态文档。
+44
View File
@@ -0,0 +1,44 @@
# 发布流程
CLICD 的安装和升级依赖 GitHub Release 产物。发布时建议使用语义化版本标签,例如 `v1.1.6`
## 版本号
版本号需要同步检查:
- `backend/internal/version/version.go`
- `frontend/package.json`
- Release 标签。
## Release 产物
安装脚本会优先下载 Linux AMD64 产物:
```text
clicd-linux-amd64.tar.gz
```
在部分场景中也会尝试下载单独二进制:
```text
clicd-linux-amd64
```
## 安装脚本行为
- `CLICD_VERSION=latest`:使用 GitHub `releases/latest`
- `CLICD_VERSION=vX.Y.Z`:下载指定标签的 Release 产物。
示例:
```bash
CLICD_VERSION=v1.1.6 sh install.sh
```
## 发布后验证
- 安装脚本可以下载新版本。
- `systemctl status clicd` 正常。
- `/api/version` 返回新版本。
- Web 面板可以加载前端资源。
- 容器列表、任务队列、API Key 页面可以正常打开。
+136
View File
@@ -0,0 +1,136 @@
# API 集成
CLICD 对外推荐使用 `/api/v1` 接口。旧版未带版本号的接口主要用于 Web 面板和兼容场景,新接入请优先使用 `/api/v1`
## 认证
API Key 可在“API 集成”页面创建和管理。请求时支持两种写法:
```bash
curl -H "X-API-Key: YOUR_API_KEY" https://panel.example.com/api/v1/containers
```
```bash
curl -H "Authorization: Bearer YOUR_API_KEY" https://panel.example.com/api/v1/dashboard
```
## Python 示例
```python
import requests
BASE_URL = "https://panel.example.com"
API_KEY = "YOUR_API_KEY"
session = requests.Session()
session.headers.update({
"X-API-Key": API_KEY,
"Content-Type": "application/json",
})
resp = session.get(f"{BASE_URL}/api/v1/containers", timeout=15)
resp.raise_for_status()
containers = resp.json()
print(containers)
```
创建端口映射:
```python
import requests
BASE_URL = "https://panel.example.com"
API_KEY = "YOUR_API_KEY"
CONTAINER_ID = "example-vm"
payload = {
"name": "web",
"protocol": "tcp",
"host_port": 18080,
"container_port": 80,
}
resp = requests.post(
f"{BASE_URL}/api/v1/containers/{CONTAINER_ID}/port-mappings",
headers={"X-API-Key": API_KEY},
json=payload,
timeout=15,
)
resp.raise_for_status()
print(resp.json())
```
## 返回结构示例
容器列表:
```json
{
"success": true,
"data": [
{
"id": 5,
"uuid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"name": "example-vm",
"status": "running",
"ip": "10.0.3.25",
"ipv6": "2001:db8:100::1005",
"cpu_limit": 2,
"memory_limit": 2048,
"disk_limit": 20480,
"traffic_limit": 107374182400,
"expires_at": "2026-12-31 23:59:59"
}
]
}
```
任务队列:
```json
{
"success": true,
"data": [
{
"id": "task-13",
"type": "restart",
"status": "running",
"created_at": "2026-06-09T10:00:00+08:00"
}
]
}
```
WebSSH 票据:
```json
{
"success": true,
"data": {
"ticket": "***60秒有效票据***"
}
}
```
## 常用接口
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| GET | `/api/v1/dashboard` | 控制面板统计 |
| GET | `/api/v1/host-info` | 主机资源 |
| GET | `/api/v1/containers` | 容器列表 |
| POST | `/api/v1/containers` | 创建容器 |
| POST | `/api/v1/containers/{id}/start` | 开机 |
| POST | `/api/v1/containers/{id}/stop` | 关机 |
| POST | `/api/v1/containers/{id}/restart` | 重启 |
| DELETE | `/api/v1/containers/{id}/delete` | 删除 |
| GET | `/api/v1/tasks` | 任务队列 |
| GET | `/api/v1/templates` | 模板列表 |
| GET | `/api/v1/images` | 镜像管理列表 |
| GET | `/api/v1/snapshots` | 快照总览 |
| GET | `/api/v1/security/alerts` | 安全告警 |
| GET | `/api/v1/audit-logs` | 操作日志 |
| GET | `/api/v1/api-keys` | API Key 列表 |
完整接口清单请以面板内“API 集成”页面为准。
+73
View File
@@ -0,0 +1,73 @@
# 容器管理
容器管理是 CLICD 的核心模块,覆盖创建、生命周期控制、资源限制、网络映射、流量统计、密码重置和控制台访问。
## 容器列表
列表页用于扫描所有容器状态。管理员可以查看全部容器,子用户只能看到授权范围内的容器。
常见字段包括:
- ID、UUID、名称。
- 虚拟化类型。
- 运行状态。
- IP、IPv6。
- CPU、内存、磁盘限制。
- 流量使用量和流量上限。
- 到期时间。
## 创建容器
创建时需要选择模板,并设置资源配额。批量创建可以通过面板或 API 完成,适合一次性发放多个容器。
```http
POST /api/v1/containers
POST /api/v1/batch-create
```
## 生命周期操作
```http
POST /api/v1/containers/{id}/start
POST /api/v1/containers/{id}/stop
POST /api/v1/containers/{id}/restart
POST /api/v1/containers/{id}/reinstall
DELETE /api/v1/containers/{id}/delete
```
开关机、重装、删除等操作会进入任务队列。调用后可通过 `GET /api/v1/tasks` 查看执行状态。
## 资源与流量
容器详情页支持查看资源用量,调整流量限制、资源限制和到期时间。
```http
GET /api/v1/containers/{id}/usage
GET /api/v1/containers/{id}/traffic
POST /api/v1/containers/{id}/traffic-reset
PUT /api/v1/containers/{id}/traffic-limit
PUT /api/v1/containers/{id}/resource-limit
PUT /api/v1/containers/{id}/expiry
```
## NAT 端口管理
容器详情页的 NAT 端口管理支持新增、编辑和删除映射。新增和编辑会在弹窗里完成,便于集中填写名称、协议、外部端口和内部端口。
```http
GET /api/v1/containers/{id}/random-port
POST /api/v1/containers/{id}/port-mappings
PUT /api/v1/containers/{id}/port-mappings/{index}
DELETE /api/v1/containers/{id}/port-mappings/{index}
```
子用户模式下,管理员可限制子用户只能调整内部端口,避免修改宿主机对外端口和协议。
## 远程控制台
```http
POST /api/v1/ssh-ticket
POST /api/v1/vnc-ticket
```
票据只适合短时间使用,返回后应立即用于 WebSSH 或 WebVNC 连接,不要持久化保存。
+27
View File
@@ -0,0 +1,27 @@
# 控制面板
控制面板用于查看宿主机和虚拟化资源的整体状态。
## 统计项
- 容器总数、运行中数量和停止数量。
- CPU、内存、磁盘、Swap 等资源概览。
- 主机网络和路由状态入口。
- 任务队列状态。
- 安全告警摘要。
## 相关接口
```http
GET /api/v1/dashboard
GET /api/v1/host-info
GET /api/v1/routing
GET /api/v1/ipv6/status
GET /api/v1/tasks
```
API 需要携带 API Key
```bash
curl -H "X-API-Key: YOUR_API_KEY" https://panel.example.com/api/v1/dashboard
```
+21
View File
@@ -0,0 +1,21 @@
# 主机报告
主机报告用于汇总宿主机运行环境、资源状态和虚拟化依赖,适合安装后自检、故障排查或给维护人员交付环境信息。
## 查看内容
- 系统版本和内核信息。
- CPU、内存、磁盘、Swap。
- 网络状态。
- LXC/KVM 依赖状态。
- CLICD 服务状态。
## 相关接口
```http
GET /api/v1/host-report
GET /api/v1/host-info
GET /api/v1/swap
```
对外发送报告前,请先检查是否包含公网 IP、内网网段、用户名、密钥、票据或业务域名。
+29
View File
@@ -0,0 +1,29 @@
# 镜像管理
镜像管理用于维护可创建容器或虚拟机的模板。
## 支持的模板类型
项目内置了常见 Linux 发行版模板,例如 Debian、Ubuntu、Alpine、CentOS、Fedora、Arch Linux、Rocky Linux 等。KVM 模板会使用对应发行版的云镜像资源。
## 管理动作
```http
GET /api/v1/templates
GET /api/v1/images
POST /api/v1/images/download
POST /api/v1/images/cancel
DELETE /api/v1/images/delete
PUT /api/v1/images/toggle
```
- `templates` 返回可用模板定义。
- `images` 返回本地镜像状态。
- `download` 下载指定模板。
- `cancel` 取消下载任务。
- `delete` 删除本地镜像缓存。
- `toggle` 控制模板是否对创建流程可用。
## Windows 镜像说明
本项目不分发 Windows 系统镜像,也不提供绕过或规避 Windows 激活机制的功能。涉及 Windows 的下载链接应指向微软官方资源,使用者需要自行获得合法授权。
+39
View File
@@ -0,0 +1,39 @@
# 网络与路由
CLICD 提供 NAT4 端口映射、随机可用端口、IPv6 状态检查和 IPv6 分配能力。
## NAT4
NAT4 用于把宿主机端口转发到容器内部端口。典型用途:
- 转发 SSH。
- 暴露 Web 服务。
- 给子用户分配固定外部端口。
端口映射包含:
| 字段 | 说明 |
| --- | --- |
| 名称 | 用于识别用途,例如 `ssh``web`。 |
| 协议 | `tcp``udp`。 |
| 外部端口 | 宿主机对外监听端口。 |
| 内部端口 | 容器内部服务端口。 |
## IPv6
IPv6 分配要求宿主机本身拥有可路由 IPv6 地址段,并且系统路由、邻居发现或代理策略配置正确。
```http
GET /api/v1/ipv6/status
POST /api/v1/containers/{id}/ipv6
```
如果宿主机没有公网 IPv6 或上游没有正确路由,面板中分配出的地址也无法从公网访问。
## 路由状态
```http
GET /api/v1/routing
```
该接口用于查看 NAT、IPv6、端口容量等运行时状态。
+31
View File
@@ -0,0 +1,31 @@
# 安全告警
CLICD 内置基于连接行为的轻量安全告警能力。它不保存完整正常连接日志,而是关注异常行为和高风险模式。
## 覆盖场景
- 端口扫描。
- 横向扫描。
- 爆破倾向。
- SMTP 滥用。
- UDP 反射风险。
- 挖矿、代理、VPN、Tor 等可疑端口。
## 接口
```http
GET /api/v1/security/alerts
POST /api/v1/security/check
GET /api/v1/security/logs?container={name}
GET /api/v1/security/summary
GET /api/v1/security/settings
PUT /api/v1/security/settings
```
## 自动关机
安全设置中可配置告警后的自动关机策略。开启前建议先观察一段时间,确认规则不会影响正常业务。
## 日志建议
安全告警适合做风险提示,不应替代专业防火墙、入侵检测或集中日志系统。对公网暴露服务时,仍建议结合安全组、防火墙、Fail2ban 等工具。
+31
View File
@@ -0,0 +1,31 @@
# 快照管理
快照用于保存容器当前状态,方便在升级、变更配置或交付前回滚。
## 全局总览
```http
GET /api/v1/snapshots
```
用于查看所有容器的快照概览。
## 容器快照
```http
GET /api/v1/containers/{id}/snapshots
POST /api/v1/containers/{id}/snapshots
DELETE /api/v1/containers/{id}/snapshots/{snapshot_id}
POST /api/v1/containers/{id}/snapshots/{snapshot_id}/restore
```
恢复快照会改变容器状态,生产环境建议先确认当前业务是否可以中断。
## 计划快照与配额
```http
POST /api/v1/containers/{id}/snapshots/schedule
PUT /api/v1/containers/{id}/snapshots/quota
```
计划快照适合长期运行的容器。配额用于避免快照无限增长占满宿主机磁盘。
+28
View File
@@ -0,0 +1,28 @@
# 子用户
子用户用于把指定容器授权给其他用户管理。它适合临时交付、拼车分配、教学实验或多人共用宿主机的场景。
## 创建访问链接
管理员选择容器后创建子用户链接:
```http
POST /api/v1/sub-user/create
```
返回内容中可能包含用户名、初始密码、访问码或访问链接。对外展示时必须脱敏,真实值只应发送给对应用户。
## 管理子用户
```http
GET /api/v1/sub-users
POST /api/v1/sub-users/{id}/rotate-password
GET /api/v1/sub-users/{id}/audit-logs
GET /api/v1/sub-users/{id}/login-logs
```
轮换密码会让旧凭证失效。审计日志和登录日志可用于排查误操作或异常访问。
## 权限范围
子用户只能管理被授权的容器。涉及全局配置、镜像管理、安全策略、API Key 等管理员功能不会开放给子用户。
+30
View File
@@ -0,0 +1,30 @@
# 配置说明
CLICD 安装后会以 systemd 服务运行,运行时配置和数据库保存在宿主机本地。实际路径可能随安装脚本参数变化,默认安装建议以 `/root/.clicd/` 为主要检查位置。
## 常见配置项
| 配置 | 说明 |
| --- | --- |
| Web 端口 | 默认 `8999`,服务启动时监听 `0.0.0.0:8999`。 |
| 管理员账号 | 用于登录 Web 面板和管理 API Key。 |
| 数据库 | SQLite,用于保存容器元数据、子用户、审计日志、API Key 等。 |
| NAT 端口范围 | 用于随机端口和端口映射分配。 |
| IPv6 地址段 | 宿主机有可路由 IPv6 时可配置分配策略。 |
| 安全告警 | 可配置自动关机等策略。 |
## 服务命令
```bash
systemctl status clicd
systemctl restart clicd
journalctl -u clicd -n 100 --no-pager
```
## 安全建议
- 不要把 Web 面板直接暴露给不可信来源。
- 使用复杂管理员密码,并定期轮换。
- API Key 按用途拆分权限,避免长期使用全权限密钥。
- WebSSH、WebVNC 票据是短期凭证,不应写入日志或外发。
- 对外文档、截图和工单里不要粘贴真实 IP、密码、API Key 或票据。
+46
View File
@@ -0,0 +1,46 @@
# 安装
CLICD 提供一键安装脚本。脚本默认安装 GitHub Releases 的最新版本,也可以通过环境变量指定固定版本。
## 环境要求
- Linux x86_64 宿主机。
- root 权限。
- systemd。
- 网络可访问 GitHub Release 下载地址。
- 如果要使用 LXC,需要宿主机支持 LXC 运行环境。
- 如果要使用 KVM,需要宿主机开启虚拟化并安装 libvirt/QEMU。
## 安装最新版本
```bash
curl -fsSL https://raw.githubusercontent.com/MengMengCode/CLICD/main/install.sh | sudo sh
```
脚本当前默认使用 `CLICD_VERSION=latest`,也就是下载 `releases/latest` 对应的 `clicd-linux-amd64.tar.gz`
## 安装指定版本
```bash
curl -fsSL https://raw.githubusercontent.com/MengMengCode/CLICD/main/install.sh | sudo CLICD_VERSION=v1.1.6 sh
```
`v1.1.6` 替换成需要安装的 Release 标签即可。
## 访问面板
安装完成后,浏览器访问:
```text
http://YOUR_SERVER_IP:8999
```
首次登录请使用安装脚本输出的管理员账号信息。生产环境建议在防火墙或反向代理层限制访问来源,并尽快修改默认账号和密码。
## 卸载
```bash
curl -fsSL https://raw.githubusercontent.com/MengMengCode/CLICD/main/install.sh | sudo sh -s -- uninstall
```
卸载前请确认是否需要保留容器、镜像缓存、数据库和配置文件。
+29
View File
@@ -0,0 +1,29 @@
# 项目介绍
CLICD 是一个面向 LXC/KVM 的轻量虚拟化管理面板。它把常见宿主机运维动作收敛到 Web 控制台和命令行里,适合用来管理小型 VPS、独立服务器或需要批量分发容器访问权限的场景。
## 核心能力
- 管理 LXC 容器和 KVM 虚拟机。
- 创建、开机、关机、重启、重装、删除容器。
- 配置 CPU、内存、磁盘、流量限制和到期时间。
- 管理 NAT4 端口映射,并在宿主机具备 IPv6 路由时分配公网 IPv6。
- 在浏览器中打开 WebSSH 或 WebVNC。
- 管理镜像下载、启用状态和本地缓存。
- 创建、恢复、删除快照,配置计划快照和快照配额。
- 基于连接行为生成安全告警,并保留审计日志。
- 为指定容器创建子用户访问链接。
- 通过 API Key 接入 `/api/v1` 自动化接口。
## 适用场景
- 一台宿主机上需要快速分配多个 Linux 容器。
- 需要给用户临时发放容器控制台、SSH、VNC 或 NAT 端口管理权限。
- 希望用 API 自动化创建容器、调整资源、重置密码或回收资源。
- 需要一个比纯 CLI 更直观,但又不重型的平台面板。
## 技术栈
- 后端:Go、`net/http`、SQLite、systemd、LXC、KVM/libvirt、cgroup v2、iptables、conntrack。
- 前端:React、TypeScript、Vite、Tailwind CSS、lucide-react、xterm.js、noVNC。
- 发布:GitHub Actions 构建 Linux AMD64 release 产物,安装脚本默认拉取最新 Release。
+36
View File
@@ -0,0 +1,36 @@
# 快速上手
下面是一条从安装后到创建第一台容器的常用路径。
## 1. 登录控制台
访问 `http://YOUR_SERVER_IP:8999`,使用管理员账号登录。
进入面板后先检查:
- 控制面板是否显示主机资源。
- 镜像管理是否能列出模板。
- 路由管理中 NAT 和 IPv6 状态是否符合宿主机预期。
## 2. 下载镜像
进入“镜像管理”,选择需要的模板并下载。宿主机资源较小时,可以优先选择 Alpine、Debian 这类轻量镜像。
镜像下载是异步任务,可以在任务队列中观察进度。
## 3. 创建容器
进入“容器管理”,点击创建:
- 选择虚拟化类型和模板。
- 设置 CPU、内存、磁盘。
- 设置流量限制和到期时间。
- 如果需要外部访问,创建后到容器详情里添加 NAT 端口映射或分配 IPv6。
## 4. 打开终端
容器创建完成后,可以在详情页打开 WebSSH。KVM 虚拟机可使用 WebVNC 查看控制台。
## 5. 分发给子用户
如果需要把某个容器交给其他用户管理,进入“子用户管理”创建访问链接。子用户只会看到被授权的容器,并受到管理员配置的操作范围限制。
+43
View File
@@ -0,0 +1,43 @@
# 升级
CLICD 的安装脚本和 CLI 都围绕 GitHub Release 产物工作。升级前建议先确认当前版本、备份配置和数据库。
## 查看版本
Web 面板侧边栏底部会显示当前版本,也可以访问:
```bash
curl http://127.0.0.1:8999/api/version
```
返回示例:
```json
{
"success": true,
"data": {
"version": "1.1.6"
}
}
```
## 使用安装脚本升级
安装脚本默认使用最新 Release
```bash
curl -fsSL https://raw.githubusercontent.com/MengMengCode/CLICD/main/install.sh | sudo sh
```
指定版本:
```bash
curl -fsSL https://raw.githubusercontent.com/MengMengCode/CLICD/main/install.sh | sudo CLICD_VERSION=v1.1.6 sh
```
## 升级前检查
- 确认 `/root/.clicd/` 或实际配置目录已备份。
- 确认系统服务没有正在执行关键任务。
- 如果正在下载镜像或恢复快照,建议等待任务完成后再升级。
- 升级后检查 `systemctl status clicd` 和 Web 面板版本号。
+23
View File
@@ -0,0 +1,23 @@
---
layout: home
hero:
name: CLICD
text: 轻量 LXC/KVM 虚拟化管理面板
tagline: 提供 Web 控制台、CLI、容器编排、NAT/IPv6、快照、安全告警、子用户和 API 自动化能力。
actions:
- theme: brand
text: 开始安装
link: /guide/installation
- theme: alt
text: 查看 API
link: /features/api
features:
- title: 面向小型宿主机
details: 适合在单台 VPS 或独立服务器上管理 LXC 容器与 KVM 虚拟机。
- title: Web 与 CLI 并行
details: 管理员可使用 Web 面板,也可以进入 clicd CLI 完成维护操作。
- title: 自动化友好
details: /api/v1 提供容器、镜像、快照、安全、日志、子用户和 API Key 管理接口。
---
+46
View File
@@ -0,0 +1,46 @@
# 部署建议
CLICD 可以直接运行在宿主机上,也可以放在反向代理之后。生产环境建议先做好访问控制,再开放给管理员使用。
## 服务暴露
默认 Web 端口为 `8999`
```text
http://YOUR_SERVER_IP:8999
```
建议:
- 仅允许固定管理员 IP 访问。
- 使用反向代理配置 HTTPS。
- 不要在公开文档或截图里暴露真实登录地址。
## systemd
常用命令:
```bash
systemctl status clicd
systemctl restart clicd
systemctl enable clicd
journalctl -u clicd -f
```
## 防火墙
至少确认:
- 面板端口只对可信来源开放。
- NAT 映射端口按需开放。
- SSH 管理端口不与容器映射冲突。
- IPv6 防火墙规则与 IPv4 同步规划。
## 备份
建议定期备份:
- CLICD 配置目录。
- SQLite 数据库。
- 容器配置。
- 关键容器的快照或外部数据备份。
+29
View File
@@ -0,0 +1,29 @@
# 常见问题
## 安装脚本默认安装哪个版本?
默认安装 GitHub Releases 的最新版本。脚本中默认值是 `CLICD_VERSION=latest`,会下载 `releases/latest` 下的 Linux AMD64 产物。
## 可以固定安装某个版本吗?
可以:
```bash
curl -fsSL https://raw.githubusercontent.com/MengMengCode/CLICD/main/install.sh | sudo CLICD_VERSION=v1.1.6 sh
```
## 子用户能看到全部容器吗?
不能。子用户只会看到管理员授权给他的容器。
## API Key 和登录密码一样吗?
不一样。API Key 在“API 集成”页面创建,用于程序化调用接口。登录密码用于 Web 面板登录。
## 到达流量限制后会怎样?
容器达到流量限制后会被自动关机,避免继续产生超额流量。管理员可以调整限制或重置流量。
## IPv6 分配后为什么公网不通?
IPv6 是否可达取决于宿主机和上游网络。需要确认宿主机拥有可路由 IPv6 地址段,并且路由、防火墙、邻居发现或代理配置正确。
+46
View File
@@ -0,0 +1,46 @@
# 故障排查
## 服务无法访问
检查服务状态:
```bash
systemctl status clicd
journalctl -u clicd -n 100 --no-pager
```
检查端口监听:
```bash
ss -lntp | grep 8999
```
如果使用反向代理,请同时检查代理日志和上游地址。
## 镜像下载失败
- 确认宿主机可以访问镜像源和 GitHub Release。
- 检查磁盘空间。
- 在任务队列里查看失败原因。
- 如下载卡住,可尝试取消任务后重新下载。
## 容器无法联网
- 检查宿主机 NAT 和转发规则。
- 检查容器 IP 是否分配成功。
- 检查防火墙是否拦截转发流量。
- IPv6 场景下确认上游已经把地址段路由到宿主机。
## WebSSH 或 WebVNC 连接失败
- 确认容器或虚拟机正在运行。
- WebSSH 需要容器内 SSH 服务可用。
- WebVNC 需要 KVM 控制台可访问。
- 票据有效期很短,过期后重新创建即可。
## API 返回未授权
- 确认 API Key 没有被禁用。
- 确认请求头使用 `X-API-Key``Authorization: Bearer`
- 确认密钥权限范围覆盖目标接口。
- 不要把面板登录密码当作 API Key 使用。
+2511
View File
File diff suppressed because it is too large Load Diff
+13
View File
@@ -0,0 +1,13 @@
{
"name": "clicd-docs",
"private": true,
"type": "module",
"scripts": {
"dev": "vitepress dev . --host 127.0.0.1",
"build": "vitepress build .",
"preview": "vitepress preview . --host 127.0.0.1"
},
"devDependencies": {
"vitepress": "^1.6.4"
}
}
+1
View File
@@ -0,0 +1 @@
cli.cd
+1
View File
@@ -0,0 +1 @@
<svg t="1780499553554" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="4260" width="200" height="200"><path d="M852.9 147.8c4.9 0 9.1 4.2 9.1 9.1v167.8c0 4.9-4.2 9.1-9.1 9.1H171.1c-4.9 0-9.1-4.2-9.1-9.1V156.9c0-4.9 4.2-9.1 9.1-9.1h681.8m0-50H171.1c-32.5 0-59.1 26.6-59.1 59.1v167.8c0 32.5 26.6 59.1 59.1 59.1h681.8c32.5 0 59.1-26.6 59.1-59.1V156.9c0-32.5-26.6-59.1-59.1-59.1z" p-id="4261" fill="#707070"></path><path d="M290.5 214h-60v60h60v-60zM393.5 214h-60v60h60v-60zM806 214H591v60h215v-60zM852.9 417.8c4.9 0 9.1 4.2 9.1 9.1v167.8c0 4.9-4.2 9.1-9.1 9.1H171.1c-4.9 0-9.1-4.2-9.1-9.1V426.9c0-4.9 4.2-9.1 9.1-9.1h681.8m0-50H171.1c-32.5 0-59.1 26.6-59.1 59.1v167.8c0 32.5 26.6 59.1 59.1 59.1h681.8c32.5 0 59.1-26.6 59.1-59.1V426.9c0-32.5-26.6-59.1-59.1-59.1z" p-id="4262" fill="#707070"></path><path d="M290.5 484h-60v60h60v-60zM393.5 484h-60v60h60v-60zM806 484H591v60h215v-60zM852.9 687.8c4.9 0 9.1 4.2 9.1 9.1v167.8c0 4.9-4.2 9.1-9.1 9.1H171.1c-4.9 0-9.1-4.2-9.1-9.1V696.9c0-4.9 4.2-9.1 9.1-9.1h681.8m0-50H171.1c-32.5 0-59.1 26.6-59.1 59.1v167.8c0 32.5 26.6 59.1 59.1 59.1h681.8c32.5 0 59.1-26.6 59.1-59.1V696.9c0-32.5-26.6-59.1-59.1-59.1z" p-id="4263" fill="#707070"></path><path d="M290.5 754h-60v60h60v-60zM393.5 754h-60v60h60v-60zM806 754H591v60h215v-60z" p-id="4264" fill="#707070"></path></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+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",
"private": true,
"version": "1.0.0",
"version": "1.1.7",
"type": "module",
"scripts": {
"dev": "vite",
@@ -9,6 +9,7 @@
"preview": "vite preview"
},
"dependencies": {
"@novnc/novnc": "1.5.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"axios": "^1.7.7",
@@ -20,11 +21,11 @@
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"@vitejs/plugin-react": "^5.2.0",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.15",
"typescript": "^5.6.3",
"vite": "^5.4.11"
"vite": "^8.0.16"
}
}
+6 -2
View File
@@ -4,14 +4,16 @@ import Login from './pages/Login'
import Dashboard from './pages/Dashboard'
import Containers from './pages/Containers'
import ContainerDetail from './pages/ContainerDetail'
import Oversell from './pages/Oversell'
import Security from './pages/Security'
import AuditLogs from './pages/AuditLogs'
import ApiIntegration from './pages/ApiIntegration'
import HostReport from './pages/HostReport'
import Settings from './pages/Settings'
import ImageManagement from './pages/ImageManagement'
import Snapshots from './pages/Snapshots'
import Routing from './pages/Routing'
import SubUserManagement from './pages/SubUserManagement'
import Layout from './components/Layout'
function ProtectedRoute({ children }: { children: React.ReactNode }) {
@@ -57,12 +59,14 @@ function App() {
<Route path="containers" element={<Containers />} />
<Route path="images" element={<ImageManagement />} />
<Route path="container/:id" element={<ContainerDetail />} />
<Route path="oversell" element={<Oversell />} />
<Route path="security" element={<Security />} />
<Route path="snapshots" element={<Snapshots />} />
<Route path="routing" element={<Routing />} />
<Route path="audit-logs" element={<AuditLogs />} />
<Route path="api-integration" element={<ApiIntegration />} />
<Route path="host-report" element={<HostReport />} />
<Route path="sub-users" element={<SubUserManagement />} />
<Route path="settings" element={<Settings />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
+1 -1
View File
@@ -56,7 +56,7 @@ export default function ContainerCard({ container, onRefresh }: ContainerCardPro
{/* Header */}
<div className="flex items-center justify-between mb-4">
<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" />
</div>
<div>
+188 -34
View File
@@ -7,10 +7,12 @@ interface CreateContainerModalProps {
isOpen: boolean
onClose: () => void
onSuccess: (containers: CreateContainerRequest[]) => void | Promise<void>
existingNames?: string[]
}
const defaultForm: CreateContainerRequest = {
name: '',
virtualization: 'lxc',
template_id: '',
vcpu: 1,
cpu_percent: 100,
@@ -24,12 +26,12 @@ const defaultForm: CreateContainerRequest = {
io_speed_mbps: 0,
extra_ports: [],
port_mapping_count: 2,
snapshot_limit: 3,
snapshot_limit: 1,
assign_ipv6: false,
expires_at: '',
}
export default function CreateContainerModal({ isOpen, onClose, onSuccess }: CreateContainerModalProps) {
export default function CreateContainerModal({ isOpen, onClose, onSuccess, existingNames = [] }: CreateContainerModalProps) {
const dialog = useDialog()
const [templates, setTemplates] = useState<Template[]>([])
const [loading, setLoading] = useState(false)
@@ -37,17 +39,19 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess }: Cre
const [form, setForm] = useState<CreateContainerRequest>(defaultForm)
const [hostInfo, setHostInfo] = useState<HostInfo | null>(null)
const [ipv6Status, setIPv6Status] = useState<IPv6Status | null>(null)
const [nameError, setNameError] = useState('')
useEffect(() => {
if (!isOpen) return
getEnabledImages()
getEnabledImages(form.virtualization)
.then((res) => {
const data = res.data.data || []
setTemplates(data)
if (data.length > 0) {
setForm((prev) => ({ ...prev, template_id: prev.template_id || data[0].id }))
}
setForm((prev) => {
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)
@@ -67,13 +71,14 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess }: Cre
getHostInfo()
.then((res) => setHostInfo(res.data.data || null))
.catch(() => setHostInfo(null))
}, [isOpen])
}, [isOpen, form.virtualization])
const ipv6Available = !!ipv6Status?.available
const ipv6Prefix = ipv6Status?.prefixes?.[0]?.prefix || ''
const maxVCPU = hostInfo?.cpu.cores || 64
const maxRAMMB = hostInfo?.ram.total_mb ? Number(hostInfo.ram.total_mb) : undefined
const maxDiskGB = hostInfo?.disk.total_gb ? Math.max(1, Math.floor(hostInfo.disk.total_gb)) : undefined
const resourceErrors = validateResourceInputs(form, maxVCPU, maxRAMMB, maxDiskGB)
const autoPorts = useMemo(() => {
const count = Math.max(2, form.port_mapping_count)
@@ -83,18 +88,52 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess }: Cre
// SSH port preview (will be allocated sequentially, starting around 22000+)
const sshPortPreview = 22000
// Find next available batch index to avoid name conflicts
const batchStartIndex = useMemo(() => {
if (batchCount <= 1 || !form.name) return 1
const prefix = `${form.name}-`
let maxIdx = 0
for (const existing of existingNames) {
if (existing.startsWith(prefix)) {
const suffix = existing.slice(prefix.length)
const idx = parseInt(suffix, 10)
if (!isNaN(idx) && idx > maxIdx) {
maxIdx = idx
}
}
}
return maxIdx + 1
}, [form.name, batchCount, existingNames])
const handleNameChange = (value: string) => {
setForm({ ...form, name: value })
if (/\s/.test(value)) {
setNameError('容器名称不能包含空格')
} else if (value && existingNames.includes(value) && batchCount === 1) {
setNameError('该容器名称已存在')
} else {
setNameError('')
}
}
const handleSubmit = async () => {
if (!form.name || !form.template_id) {
dialog.alert('提示', '请填写容器名称并选择系统模板')
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
const containers: CreateContainerRequest[] = []
const startIndex = batchStartIndex
for (let i = 0; i < batchCount; i++) {
const name = batchCount > 1 ? `${boundedForm.name}-${i + 1}` : boundedForm.name
const name = batchCount > 1 ? `${boundedForm.name}-${startIndex + i}` : boundedForm.name
containers.push({
...boundedForm,
name,
@@ -137,27 +176,47 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess }: Cre
<input
type="text"
value={form.name}
onChange={(event) => setForm({ ...form, name: event.target.value })}
className={inputClass}
onChange={(event) => handleNameChange(event.target.value)}
className={`${inputClass} ${nameError ? 'border-red-400 focus:ring-red-400 focus:border-red-400' : ''}`}
placeholder="my-container"
required
/>
{nameError && <p className="text-xs text-red-500 mt-1">{nameError}</p>}
</Field>
<Field label="批量创建数量">
<NumberInput value={batchCount} min={1} max={50} onChange={(value) => setBatchCount(Math.max(1, value || 1))} />
</Field>
</div>
{batchCount > 1 && <p className="text-xs text-gray-400"> {batchCount} {form.name}-1 {form.name}-{batchCount}</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="系统模板">
{templates.length === 0 ? (
<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>
) : (
<select
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}
>
{templates.map((template) => (
@@ -167,6 +226,7 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess }: Cre
))}
</select>
)}
</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'}`}>
@@ -187,16 +247,39 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess }: Cre
<div className="grid grid-cols-2 gap-4">
<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 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>
</div>
<div className="grid grid-cols-3 gap-3">
<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 label="带宽 (Mbps)">
<NumberInput value={form.network_bw_mbps} min={0} onChange={(value) => setForm({ ...form, network_bw_mbps: value })} />
@@ -245,7 +328,7 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess }: Cre
/>
<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">
SSH: {sshPortPreview} -&gt; 22
{isWindowsTemplate(form.template_id) ? 'RDP' : 'SSH'}: {sshPortPreview} -&gt; {isWindowsTemplate(form.template_id) ? 3389 : 22}
</span>
{autoPorts.map((port) => (
<span key={port} className="inline-flex px-2 py-1 bg-gray-100 text-gray-700 rounded text-xs font-mono">
@@ -310,44 +393,115 @@ function NumberInput({
min,
max,
step,
invalid,
onChange,
}: {
value: number
min?: number
max?: number
step?: number
invalid?: boolean
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 (
<input
type="number"
value={value}
min={min}
max={max}
step={step}
type="text"
inputMode={step && !Number.isInteger(step) ? 'decimal' : 'numeric'}
value={draft}
onFocus={() => setFocused(true)}
onBlur={() => {
setFocused(false)
setDraft(Number.isFinite(value) ? String(value) : '')
}}
onChange={(event) => {
const raw = event.target.value
const value = step && !Number.isInteger(step) ? parseFloat(raw) : parseInt(raw, 10)
onChange(value)
setDraft(raw)
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 {
...form,
vcpu: clampVCPU(form.vcpu, maxVCPU),
ram_mb: clampInt(form.ram_mb, 128, maxRAMMB, 512),
disk_gb: clampInt(form.disk_gb, 1, maxDiskGB, 10),
snapshot_limit: clampInt(form.snapshot_limit, 1, undefined, 3),
...normalized,
vcpu: normalized.virtualization === 'kvm' ? Math.round(normalized.vcpu) : normalizeLXCvCPU(normalized.vcpu),
ram_mb: Math.round(normalized.ram_mb),
disk_gb: Math.round(normalized.disk_gb),
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
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) {
+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 circumference = radius * 2 * Math.PI
const percentage = Math.min(Math.max(value / max * 100, 0), 100)
const strokeDashoffset = circumference - (percentage / 100) * circumference
const percentage = max === Infinity ? Math.max(value, 0) : Math.min(Math.max(value / max * 100, 0), 100)
const strokeDashoffset = circumference - (Math.min(percentage, 100) / 100) * circumference
const bgStroke = isDark ? '#374151' : '#f3f4f6'
const progressStroke = isDark ? '#f9fafb' : '#000000'
@@ -51,7 +51,7 @@ export function RingStat({ value, max = 100, label, subLabel, size = 120, stroke
</svg>
{/* Center value */}
<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 className="mt-2 text-center">
@@ -73,7 +73,6 @@ interface RingStatsProps {
swapUsed?: number
swapTotal?: number
loadPercent: number
loadStatus: string
diskPercent: number
diskUsed: number
diskTotal: number
@@ -90,7 +89,6 @@ export default function RingStats({
swapUsed = 0,
swapTotal = 0,
loadPercent,
loadStatus,
diskPercent,
diskUsed,
diskTotal,
@@ -125,8 +123,8 @@ export default function RingStats({
)}
<RingStat
value={loadPercent}
max={Infinity}
label="负载"
subLabel={loadStatus}
/>
<RingStat
value={diskPercent}
+69 -18
View File
@@ -4,6 +4,7 @@ import {
ChevronLeft,
ChevronRight,
Code2,
Cpu,
Camera,
LayoutDashboard,
LogOut,
@@ -12,7 +13,7 @@ import {
Route,
ScrollText,
Server,
Settings2,
ShieldAlert,
Sun,
UserCog,
@@ -27,6 +28,23 @@ interface SidebarProps {
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) {
const navigate = useNavigate()
const location = useLocation()
@@ -49,11 +67,12 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
location.pathname.startsWith('/container')
const isImagesPage = location.pathname.startsWith('/images')
const isOversellPage = location.pathname.startsWith('/oversell')
const isSnapshotsPage = location.pathname.startsWith('/snapshots')
const isRoutingPage = location.pathname.startsWith('/routing')
const isAuditLogsPage = location.pathname.startsWith('/audit-logs')
const isApiIntegrationPage = location.pathname.startsWith('/api-integration')
const isHostReportPage = location.pathname.startsWith('/host-report')
const isSecurityPage = location.pathname.startsWith('/security')
const isSettingsPage = location.pathname.startsWith('/settings')
@@ -66,14 +85,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">
{!collapsed && (
<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" />
</div>
<span className="font-bold text-black text-sm dark:text-white">CLICD</span>
</div>
)}
{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" />
</div>
)}
@@ -133,18 +152,6 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
{!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
onClick={() => navigate('/security')}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
@@ -193,6 +200,18 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
{!collapsed && <span></span>}
</button>
<button
onClick={() => navigate('/sub-users')}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
location.pathname.startsWith('/sub-users')
? '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'
}`}
>
<UserCog className="w-4 h-4" />
{!collapsed && <span></span>}
</button>
<button
onClick={() => navigate('/api-integration')}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
@@ -205,6 +224,18 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
{!collapsed && <span>API </span>}
</button>
<button
onClick={() => navigate('/host-report')}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
isHostReportPage
? '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'
}`}
>
<Cpu className="w-4 h-4" />
{!collapsed && <span>宿</span>}
</button>
<button
onClick={() => navigate('/settings')}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
@@ -239,9 +270,29 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
{version && (
<div className={`px-3 py-2 text-xs text-gray-400 dark:text-gray-500 ${collapsed ? 'text-center' : ''}`}>
{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>
)}
+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
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+311 -43
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState, type ReactNode } from 'react'
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'
import { useNavigate } from 'react-router-dom'
import {
ArrowDown,
@@ -12,6 +12,7 @@ import {
Plus,
RefreshCw,
RotateCcw,
Search,
Server,
Square,
Trash2,
@@ -46,6 +47,12 @@ export default function Containers() {
const [showTasks, setShowTasks] = useState(false)
const [tasks, setTasks] = useState<Task[]>([])
const [queuedCreates, setQueuedCreates] = useState<Record<string, CreateContainerRequest>>({})
const [searchText, setSearchText] = useState('')
const [typeFilter, setTypeFilter] = useState('all')
const [systemFilter, setSystemFilter] = useState('all')
const [statusFilter, setStatusFilter] = useState('all')
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const refreshUsage = useCallback(async (items: Container[]) => {
const targets = items.filter((container) => container.status === 'running')
@@ -102,18 +109,6 @@ export default function Containers() {
})
}
const toggleAll = () => {
const selectableIDs = displayContainers
.filter((container) => !container.isPlaceholder && !taskStatusMap[container.id] && !taskNameMap[container.name])
.map((container) => container.id)
if (selected.size === selectableIDs.length) {
setSelected(new Set())
} else {
setSelected(new Set(selectableIDs))
}
}
// Map of container_id -> current task status.
// For create tasks, container_id may be 0 initially but gets set after creation,
// so we also index by container_name as fallback for placeholder items.
@@ -170,6 +165,45 @@ export default function Containers() {
const displayContainers = buildDisplayContainers(containers, queuedCreates, tasks)
const activeTaskCount = tasks.filter((task) => task.status === 'pending' || task.status === 'running').length
const systemOptions = useMemo(() => buildSystemOptions(displayContainers), [displayContainers])
const filteredContainers = useMemo(() => {
return filterContainers(displayContainers, {
search: searchText,
type: typeFilter,
system: systemFilter,
status: statusFilter,
taskStatusMap,
taskNameMap,
})
}, [displayContainers, searchText, typeFilter, systemFilter, statusFilter, tasks])
const totalPages = Math.max(1, Math.ceil(filteredContainers.length / pageSize))
const currentPage = Math.min(page, totalPages)
const pageStart = (currentPage - 1) * pageSize
const pageContainers = filteredContainers.slice(pageStart, pageStart + pageSize)
const selectableIDs = filteredContainers
.filter((container) => !container.isPlaceholder && !taskStatusMap[container.id] && !taskNameMap[container.name])
.map((container) => container.id)
const allFilteredSelected = selectableIDs.length > 0 && selectableIDs.every((id) => selected.has(id))
useEffect(() => {
setPage(1)
}, [searchText, typeFilter, systemFilter, statusFilter, pageSize])
const toggleAll = () => {
if (allFilteredSelected) {
setSelected((prev) => {
const next = new Set(prev)
selectableIDs.forEach((id) => next.delete(id))
return next
})
} else {
setSelected((prev) => {
const next = new Set(prev)
selectableIDs.forEach((id) => next.add(id))
return next
})
}
}
const handleCreateQueued = async (items: CreateContainerRequest[]) => {
setQueuedCreates((current) => {
@@ -193,29 +227,16 @@ export default function Containers() {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-[180px]">
<h1 className="text-2xl font-bold text-black"></h1>
<p className="text-sm text-gray-500 mt-1"> {displayContainers.length} {selected.size > 0 && `,已选 ${selected.size}`}</p>
<p className="text-sm text-gray-500 mt-1">
{displayContainers.length}
{filteredContainers.length !== displayContainers.length && `,筛选后 ${filteredContainers.length}`}
{selected.size > 0 && `,已选 ${selected.size}`}
</p>
</div>
<div className="flex items-center 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>
)}
<div className="flex flex-wrap items-center justify-end gap-2">
<button
onClick={handleRefreshList}
disabled={refreshing}
@@ -249,6 +270,84 @@ export default function Containers() {
</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 ? (
<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">
@@ -260,14 +359,15 @@ export default function Containers() {
) : (
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full min-w-[1200px]">
<table className="w-full min-w-[1260px]">
<thead>
<tr className="border-b border-gray-200 bg-gray-50">
<th className="w-10 px-3 py-3">
{!isSubUser && (
<input
type="checkbox"
checked={displayContainers.length > 0 && selected.size === displayContainers.filter((container) => !container.isPlaceholder && !taskStatusMap[container.id] && !taskNameMap[container.name]).length}
checked={allFilteredSelected}
disabled={selectableIDs.length === 0}
onChange={toggleAll}
className="w-4 h-4 rounded border-gray-300 text-black focus:ring-black accent-black"
/>
@@ -277,6 +377,7 @@ export default function Containers() {
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></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><HardDrive className="w-3.5 h-3.5" />DISK</TableHead>
@@ -287,15 +388,22 @@ export default function Containers() {
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{displayContainers.map((container) => {
{pageContainers.map((container) => {
const isRunning = container.status === 'running'
const task = (container.id > 0 ? taskStatusMap[container.id] : taskNameMap[container.name]) || container.createTask
const isPlaceholder = !!container.isPlaceholder
const isPolicyBlocked = !!container.policy_blocked
const usage = usageByName[container.name]
const isKVM = (container.virtualization || 'lxc') === 'kvm'
const cpuPct = isRunning ? clamp(usage?.cpu_usage_pct || 0) : 0
const ramPct = isRunning && container.ram_mb > 0
? clamp(((usage?.memory_usage_bytes || 0) / (container.ram_mb * 1024 * 1024)) * 100)
const cpuPct = isRunning
? clamp((usage?.cpu_usage_pct || 0) / (isKVM ? (container.vcpu || 1) : 1))
: 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
const diskPct = container.disk_gb > 0
? clamp(((usage?.disk_usage_bytes || 0) / (container.disk_gb * 1024 * 1024 * 1024)) * 100)
@@ -329,7 +437,7 @@ export default function Containers() {
</button>
</td>
<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 className="px-2.5 py-2 align-top text-xs text-gray-600 whitespace-nowrap">
<span className="inline-flex items-center gap-1">
@@ -337,6 +445,9 @@ export default function Containers() {
{getTemplateName(container.template)}
</span>
</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">
<ProgressCell pct={cpuPct} />
</td>
@@ -398,10 +509,54 @@ export default function Containers() {
</tbody>
</table>
</div>
{filteredContainers.length === 0 ? (
<div className="border-t border-gray-100 px-4 py-10 text-center text-sm text-gray-500">
</div>
) : (
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-gray-100 px-4 py-3">
<div className="text-xs text-gray-500">
{pageStart + 1}-{Math.min(pageStart + pageSize, filteredContainers.length)} / {filteredContainers.length}
</div>
<div className="flex items-center gap-1">
<button
onClick={() => setPage(1)}
disabled={currentPage === 1}
className="rounded border border-gray-200 px-2.5 py-1.5 text-xs text-gray-600 hover:bg-gray-50 disabled:opacity-40"
>
</button>
<button
onClick={() => setPage((value) => Math.max(1, value - 1))}
disabled={currentPage === 1}
className="rounded border border-gray-200 px-2.5 py-1.5 text-xs text-gray-600 hover:bg-gray-50 disabled:opacity-40"
>
</button>
<span className="px-2 text-xs text-gray-500">
{currentPage} / {totalPages}
</span>
<button
onClick={() => setPage((value) => Math.min(totalPages, value + 1))}
disabled={currentPage === totalPages}
className="rounded border border-gray-200 px-2.5 py-1.5 text-xs text-gray-600 hover:bg-gray-50 disabled:opacity-40"
>
</button>
<button
onClick={() => setPage(totalPages)}
disabled={currentPage === totalPages}
className="rounded border border-gray-200 px-2.5 py-1.5 text-xs text-gray-600 hover:bg-gray-50 disabled:opacity-40"
>
</button>
</div>
</div>
)}
</div>
)}
<CreateContainerModal isOpen={showCreate} onClose={() => setShowCreate(false)} onSuccess={handleCreateQueued} />
<CreateContainerModal isOpen={showCreate} onClose={() => setShowCreate(false)} onSuccess={handleCreateQueued} existingNames={containers.map(c => c.name)} />
{showTasks && (
<TaskQueueModal
tasks={tasks}
@@ -426,8 +581,17 @@ type DisplayContainer = Container & {
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"
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') {
return (
<span className={`${baseClass} bg-red-50 text-red-700`}>
@@ -484,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(
containers: Container[],
queuedCreates: Record<string, CreateContainerRequest>,
@@ -515,6 +688,7 @@ function toPlaceholder(cfg: CreateContainerRequest): DisplayContainer {
id: 0,
uuid: '',
name: cfg.name,
virtualization: cfg.virtualization || 'lxc',
template: cfg.template_id,
vcpu: cfg.vcpu,
ram_mb: cfg.ram_mb,
@@ -585,6 +759,92 @@ function hasActiveTasks(tasks: Task[]) {
return tasks.some((task) => task.status === 'pending' || task.status === 'running')
}
type ContainerFilters = {
search: string
type: string
system: string
status: string
taskStatusMap: Record<number, Task>
taskNameMap: Record<string, Task>
}
function filterContainers(containers: DisplayContainer[], filters: ContainerFilters): DisplayContainer[] {
const keyword = filters.search.trim().toLowerCase()
return containers.filter((container) => {
const task = (container.id > 0 ? filters.taskStatusMap[container.id] : filters.taskNameMap[container.name]) || container.createTask
if (filters.system !== 'all' && getSystemFilterValue(container.template) !== filters.system) {
return false
}
if (filters.type !== 'all' && (container.virtualization || 'lxc') !== filters.type) {
return false
}
if (filters.status !== 'all' && getContainerStatusFilterValue(container, task) !== filters.status) {
return false
}
if (!keyword) return true
const fields = [
String(container.id),
container.name,
container.uuid,
container.ip,
container.ipv6,
container.template,
container.virtualization || 'lxc',
getTemplateName(container.template),
getSystemFilterLabel(getSystemFilterValue(container.template)),
String(container.ssh_port || ''),
]
return fields.some((field) => field.toLowerCase().includes(keyword))
})
}
function buildSystemOptions(containers: DisplayContainer[]) {
const systems = new Map<string, string>()
for (const container of containers) {
const value = getSystemFilterValue(container.template)
systems.set(value, getSystemFilterLabel(value))
}
return Array.from(systems.entries())
.map(([value, label]) => ({ value, label }))
.sort((a, b) => a.label.localeCompare(b.label))
}
function getSystemFilterValue(template: string) {
const normalized = template.startsWith('kvm-') ? template.slice(4) : template
if (normalized.startsWith('ubuntu')) return 'ubuntu'
if (normalized.startsWith('debian')) return 'debian'
if (normalized.startsWith('alpine')) return 'alpine'
if (normalized.startsWith('centos')) return 'centos'
if (normalized.startsWith('archlinux')) return 'archlinux'
if (normalized.startsWith('fedora')) return 'fedora'
if (normalized.startsWith('rockylinux')) return 'rockylinux'
if (normalized.startsWith('windows')) return 'windows'
return normalized || 'unknown'
}
function getSystemFilterLabel(system: string) {
const labels: Record<string, string> = {
ubuntu: 'Ubuntu',
debian: 'Debian',
alpine: 'Alpine',
centos: 'CentOS',
archlinux: 'Arch Linux',
fedora: 'Fedora',
rockylinux: 'Rocky Linux',
windows: 'Windows',
unknown: '未知系统',
}
return labels[system] || system
}
function getContainerStatusFilterValue(container: DisplayContainer, task?: Task) {
if (task?.status === 'failed') return 'failed'
if (container.isPlaceholder || task?.type === 'create') return 'creating'
if (task && task.status !== 'done' && task.status !== 'failed') return 'task'
return container.status === 'running' ? 'running' : 'stopped'
}
function taskLineLabel(task: Task, actionLabels: Record<string, string>) {
if (task.status === 'failed') return task.type === 'create' ? '初始化失败' : '处理失败'
if (task.type === 'create' && task.status === 'done') return '初始化完成'
@@ -697,12 +957,19 @@ function getTemplateName(id: string) {
'archlinux-current': 'Arch Linux',
'fedora-44': 'Fedora 44',
'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
}
function getTemplateIcon(id: string): ReactNode {
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('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>
@@ -710,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('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('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
}
+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}
ramUsed={host.ram.used_mb}
ramTotal={host.ram.total_mb}
loadPercent={Math.min((host.load.load1 / host.cpu.cores) * 100, 100)}
loadStatus={host.load.load1 < host.cpu.cores * 0.7 ? '正常' : host.load.load1 < host.cpu.cores * 1.0 ? '中等' : '高'}
loadPercent={(host.load.load1 / Math.max(host.cpu.cores, 1)) * 100}
diskPercent={host.disk.total_gb > 0 ? (host.disk.used_gb / host.disk.total_gb) * 100 : 0}
diskUsed={host.disk.used_gb * 1024}
diskTotal={host.disk.total_gb * 1024}
+328
View File
@@ -0,0 +1,328 @@
import { ReactNode, useCallback, useEffect, useState } from 'react'
import {
Activity,
CheckCircle2,
Cpu,
HardDrive,
MemoryStick,
RefreshCw,
XCircle,
} from 'lucide-react'
import { getHostReport, HostProbeReport } from '../services/api'
export default function HostReport() {
const [report, setReport] = useState<HostProbeReport | null>(null)
const [loading, setLoading] = useState(true)
const fetchReport = useCallback(async () => {
setLoading(true)
try {
const res = await getHostReport()
setReport(res.data.data || null)
} catch (err) {
console.error(err)
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchReport()
}, [fetchReport])
return (
<div className="space-y-6">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h1 className="text-2xl font-bold text-black">宿</h1>
<p className="mt-1 text-sm text-gray-500"></p>
</div>
<button onClick={fetchReport} disabled={loading} className="inline-flex items-center gap-1.5 rounded-md border border-gray-200 px-3 py-2 text-sm text-gray-600 hover:bg-gray-50 disabled:opacity-50">
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
</button>
</div>
{loading && !report ? (
<div className="rounded-lg border border-gray-200 bg-white py-14 text-center text-sm text-gray-400">宿...</div>
) : !report ? (
<div className="rounded-lg border border-gray-200 bg-white py-14 text-center text-sm text-gray-400">宿</div>
) : (
<div className="space-y-5">
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<ProbeMetric icon={<Cpu className="h-4 w-4" />} label="CPU" value={report.cpu.model || 'Unknown'} sub={`${report.cpu.cores} 核 / ${report.cpu.threads} 线程`} />
<ProbeMetric icon={<MemoryStick className="h-4 w-4" />} label="RAM" value={formatMB(report.memory.total_mb)} sub={`${formatMB(report.memory.used_mb)} 已用`} />
<ProbeMetric icon={<HardDrive className="h-4 w-4" />} label="DISK" value={`${report.disks.length} 块硬盘`} sub={report.disks.map(d => d.type).filter(Boolean).join(' / ') || 'Unknown'} />
<ProbeMetric icon={<Activity className="h-4 w-4" />} label="运行状态" value={report.system.uptime_text} sub={`${report.system.process_count} 个进程`} />
</div>
<ProbeSection title="系统概览">
<ProbeRows rows={[
['主机名', report.hostname],
['操作系统', report.os],
['内核', report.kernel],
['生成时间', report.generated_at],
['CPU 架构', report.cpu.architecture],
['CPU 虚拟化指令', report.cpu.virtualization ? `支持 (${report.cpu.virtualization_key})` : '未检测到'],
['CPU 核显', report.cpu.has_integrated_gpu ? '检测到' : '未检测到'],
['显卡', report.gpus.length ? `${report.gpus.length}` : '未检测到'],
['运行能力', runtimeModeLabel(report.runtime.support_mode)],
['KVM 嵌套虚拟化', `${report.runtime.nested_virtualization ? '支持' : '未检测到'} (${report.runtime.nested_detail || '-'})`],
]} />
</ProbeSection>
<ProbeSection title="公网与路由">
<ProbeRows rows={[
['公网 IPv4', report.public_ipv4.length ? report.public_ipv4.join('\n') : '未检测到'],
['IPv4 地址', report.ipv4_addresses?.length ? report.ipv4_addresses.map(formatIPv4Address).join('\n') : '未检测到'],
['IPv4 段', report.ipv4_prefixes?.length ? report.ipv4_prefixes.map(formatIPv4Prefix).join('\n') : '未检测到'],
['IPv6 地址', report.ipv6_addresses.length ? report.ipv6_addresses.map(ip => `${ip.address}/${ip.prefix_len} (${ip.interface})`).join('\n') : '未检测到'],
['IPv6 段', report.ipv6_prefixes?.length ? report.ipv6_prefixes.map(formatIPv6Prefix).join('\n') : '未检测到'],
['网关', report.gateways.length ? report.gateways.map(g => `${g.family}: ${g.gateway || '-'} dev ${g.interface || '-'}`).join('\n') : '未检测到'],
]} />
</ProbeSection>
<ProbeTable
title="内存条"
empty="未检测到内存条明细,可能缺少 dmidecode 或权限受限"
headers={['插槽', '容量', '类型', '频率', '厂商', '型号/序列号']}
rows={(report.memory.modules || []).map(m => [
m.locator || '-',
m.size || '-',
m.type || '-',
m.speed || '-',
m.manufacturer || '-',
[m.part_number, m.serial_number].filter(Boolean).join(' / ') || '-',
])}
/>
<ProbeTable
title="硬盘与健康"
empty="未检测到硬盘"
headers={['设备', '型号', '容量', '类型', '挂载点', '健康', '寿命', '通电', '读取', '写入', '命令数', '擦写']}
rows={report.disks.map(d => [
`${d.path || d.name}\n${d.serial || ''}`,
d.model || '-',
formatBytes(d.size_bytes),
d.type || (d.rotational ? 'HDD' : 'SSD'),
d.mountpoints?.length ? d.mountpoints.join('\n') : '-',
`${diskHealthLabel(d.health)}\n${d.health_detail || ''}`,
formatLifeUsed(d.smart?.life_used_percent),
d.smart?.power_on_hours ? `${d.smart.power_on_hours} 小时\n${formatPowerOnDays(d.smart.power_on_hours)}` : '-',
formatBytes(d.smart?.read_data_bytes || 0),
formatBytes(d.smart?.written_data_bytes || 0),
formatCommands(d.smart?.read_commands, d.smart?.write_commands),
formatWear(d.smart?.wear_leveling_count, d.smart?.erase_count, d.smart?.power_cycle_count),
])}
/>
<ProbeTable
title="网卡"
empty="未检测到网卡"
headers={['网卡', '状态', '驱动/速率', 'MAC', 'IPv4', 'IPv6']}
rows={report.network_interfaces.map(n => [
`${n.name}\n${n.model || ''}`,
n.state || '-',
`${n.driver || '-'}\n${n.speed_mbps > 0 ? `${n.speed_mbps} Mbps` : '-'}`,
n.mac || '-',
n.ipv4?.length ? n.ipv4.map(ip => `${ip.address}/${ip.prefix_len}`).join('\n') : '-',
n.ipv6?.length ? n.ipv6.map(ip => `${ip.address}/${ip.prefix_len} ${ip.scope}`).join('\n') : '-',
])}
/>
<ProbeTable
title="显卡"
empty="未检测到显卡"
headers={['名称', '厂商', '类型', '驱动']}
rows={report.gpus.map(g => [g.name, g.vendor || '-', gpuTypeLabel(g.type), g.driver || '-'])}
/>
<ProbeSection title="环境支持">
<div className="grid gap-2 md:grid-cols-2">
{report.environment.map(item => (
<div key={item.key} className="flex items-start gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2">
{item.ok ? <CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0 text-green-600" /> : <XCircle className={`mt-0.5 h-4 w-4 shrink-0 ${item.required ? 'text-red-600' : 'text-amber-600'}`} />}
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2 text-xs font-medium text-gray-800">
<span>{item.label}</span>
<span className={`rounded px-1.5 py-0.5 text-[10px] ${item.required ? 'bg-gray-100 text-gray-600' : 'bg-blue-50 text-blue-700'}`}>
{item.required ? '必要' : '可选'}
</span>
</div>
<div className="mt-1 break-all font-mono text-[11px] text-gray-500">{item.detail || '-'}</div>
</div>
</div>
))}
</div>
</ProbeSection>
</div>
)}
</div>
)
}
function ProbeMetric({ icon, label, value, sub }: { icon: ReactNode; label: string; value: string; sub: string }) {
return (
<div className="rounded-lg border border-gray-200 bg-white px-3 py-3">
<div className="mb-2 flex items-center gap-2 text-xs font-medium text-gray-500">
{icon}
{label}
</div>
<div className="line-clamp-2 break-words text-sm font-semibold text-gray-900" title={value}>{value}</div>
<div className="mt-1 truncate text-xs text-gray-500" title={sub}>{sub}</div>
</div>
)
}
function ProbeSection({ title, children }: { title: string; children: ReactNode }) {
return (
<section>
<h2 className="mb-2 text-sm font-semibold text-black">{title}</h2>
{children}
</section>
)
}
function ProbeRows({ rows }: { rows: Array<[string, string]> }) {
return (
<div className="overflow-hidden rounded-lg border border-gray-200 bg-white">
{rows.map(([label, value]) => (
<div key={label} className="grid gap-2 border-b border-gray-100 px-3 py-2 text-xs last:border-b-0 md:grid-cols-[160px_1fr]">
<div className="font-medium text-gray-500">{label}</div>
<div className="whitespace-pre-wrap break-words font-mono text-gray-800">{value || '-'}</div>
</div>
))}
</div>
)
}
function ProbeTable({ title, headers, rows, empty }: { title: string; headers: string[]; rows: string[][]; empty: string }) {
return (
<section>
<h2 className="mb-2 text-sm font-semibold text-black">{title}</h2>
{rows.length === 0 ? (
<div className="rounded-lg border border-gray-200 bg-white px-3 py-3 text-xs text-gray-400">{empty}</div>
) : (
<div className="overflow-x-auto rounded-lg border border-gray-200 bg-white">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-gray-100 bg-gray-50 text-left text-gray-500">
{headers.map(header => <th key={header} className="px-3 py-2 font-medium">{header}</th>)}
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{rows.map((row, rowIndex) => (
<tr key={rowIndex} className="align-top">
{row.map((cell, cellIndex) => (
<td key={cellIndex} className="max-w-[280px] whitespace-pre-wrap break-words px-3 py-2 text-gray-700">
{cell || '-'}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
)
}
function formatIPv4Address(ip: HostProbeReport['ipv4_addresses'][number]) {
return `${ip.address}/${ip.prefix_len} (${ip.interface})`
}
function formatIPv4Prefix(prefix: HostProbeReport['ipv4_prefixes'][number]) {
const parts = [
prefix.prefix || '-',
prefix.subnet_mask ? `mask ${prefix.subnet_mask}` : '',
prefix.gateway ? `via ${prefix.gateway}` : '',
prefix.interface ? `dev ${prefix.interface}` : '',
prefix.source ? `[${prefix.source}]` : '',
].filter(Boolean)
return parts.join(' ')
}
function formatIPv6Prefix(prefix: HostProbeReport['ipv6_prefixes'][number]) {
const value = prefix.prefix || prefix.address || '-'
const cidr = value.includes('/') || !prefix.prefix_len ? value : `${value}/${prefix.prefix_len}`
return `${cidr} via ${prefix.gateway || '-'}`
}
function formatMB(value: number) {
if (!value) return '-'
if (value >= 1024) return `${(value / 1024).toFixed(1)} GB`
return `${value} MB`
}
function formatBytes(value: number) {
if (!value) return '-'
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']
let next = value
let index = 0
while (next >= 1024 && index < units.length - 1) {
next /= 1024
index++
}
return `${next.toFixed(index === 0 ? 0 : 1)} ${units[index]}`
}
function formatLifeUsed(value?: number) {
if (value === undefined || value === null) return '-'
return `${value}% 已用\n${Math.max(0, 100 - value)}% 剩余`
}
function formatPowerOnDays(hours: number) {
const days = Math.floor(hours / 24)
const rest = hours % 24
return days > 0 ? `${days}${rest} 小时` : `${hours} 小时`
}
function formatCommands(read?: number, write?: number) {
if (!read && !write) return '-'
return `${formatCount(read || 0)}\n写 ${formatCount(write || 0)}`
}
function formatCount(value: number) {
if (!value) return '-'
if (value >= 1_000_000_000) return `${(value / 1_000_000_000).toFixed(1)}B`
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`
if (value >= 1_000) return `${(value / 1_000).toFixed(1)}K`
return `${value}`
}
function formatWear(wear?: string, erase?: string, powerCycles?: number) {
const rows: string[] = []
if (wear) rows.push(`磨损 ${wear}`)
if (erase) rows.push(`擦写 ${erase}`)
if (powerCycles) rows.push(`启停 ${powerCycles}`)
return rows.length ? rows.join('\n') : '-'
}
function runtimeModeLabel(value: string) {
switch (value) {
case 'kvm_lxc':
return '支持 KVM + LXC'
case 'lxc_only':
return '仅支持 LXC'
default:
return '未满足运行环境'
}
}
function diskHealthLabel(value: string) {
switch (value) {
case 'ok':
return '健康'
case 'failed':
return '异常'
default:
return '未知'
}
}
function gpuTypeLabel(value: string) {
if (value === 'integrated') return '核显'
if (value === 'discrete') return '独显'
return value || '-'
}
+153 -19
View File
@@ -9,10 +9,13 @@ import {
ToggleRight,
Loader2,
AlertCircle,
X,
} 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() {
const dialog = useDialog()
const [images, setImages] = useState<ImageInfo[]>([])
const [loading, setLoading] = useState(true)
const [actionLoading, setActionLoading] = useState<string | null>(null)
@@ -32,10 +35,14 @@ export default function ImageManagement() {
useEffect(() => {
fetchImages()
const interval = setInterval(fetchImages, 5000)
return () => clearInterval(interval)
}, [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) => {
setActionLoading(templateId)
setError('')
@@ -43,15 +50,27 @@ export default function ImageManagement() {
await downloadImage(templateId)
await fetchImages()
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : '下载失败'
setError(msg)
setError(apiErrorMessage(err, '下载失败'))
} finally {
setActionLoading(null)
}
}
const handleCancelDownload = async (templateId: string) => {
setActionLoading(templateId)
setError('')
try {
await cancelImageDownload(templateId)
await fetchImages()
} catch (err: unknown) {
setError(apiErrorMessage(err, '取消失败'))
} finally {
setActionLoading(null)
}
}
const handleDelete = async (templateId: string) => {
if (!window.confirm('确定要删除该镜像缓存吗?删除后需要重新下载才能使用。')) return
if (!(await dialog.confirm('删除镜像', '确定要删除该镜像缓存吗?删除后需要重新下载才能使用。'))) return
setActionLoading(templateId)
setError('')
try {
@@ -80,6 +99,8 @@ export default function ImageManagement() {
}
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) {
return (
@@ -95,7 +116,7 @@ export default function ImageManagement() {
<div>
<h1 className="text-2xl font-bold text-black"></h1>
<p className="text-sm text-gray-500 mt-1">
LXC
LXC / KVM /
{downloadedCount}/{images.length}
</p>
</div>
@@ -115,6 +136,62 @@ export default function ImageManagement() {
</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="overflow-x-auto">
<table className="w-full">
@@ -147,12 +224,13 @@ export default function ImageManagement() {
<tr key={img.id} className="hover:bg-gray-50 transition-colors">
<td className="px-4 py-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)}
</span>
<div>
<span className="font-medium text-gray-900 text-sm">{img.name}</span>
<p className="text-[11px] text-gray-400">{img.description}</p>
</div>
</div>
</td>
@@ -172,7 +250,7 @@ export default function ImageManagement() {
<div className="flex items-center justify-end gap-2">
{!img.downloaded && !img.downloading && (
<button
onClick={() => handleDownload(img.id)}
onClick={() => onDownload(img.id)}
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"
>
@@ -186,16 +264,21 @@ export default function ImageManagement() {
)}
{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">
<Loader2 className="w-3.5 h-3.5 animate-spin" />
...
</span>
<button
onClick={() => onCancelDownload(img.id)}
disabled={isBusy}
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
onClick={() => handleToggle(img.id, img.enabled)}
onClick={() => onToggle(img.id, img.enabled)}
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 ${
img.enabled
@@ -207,7 +290,7 @@ export default function ImageManagement() {
{img.enabled ? '启用' : '禁用'}
</button>
<button
onClick={() => handleDelete(img.id)}
onClick={() => onDelete(img.id)}
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"
title="删除镜像缓存"
@@ -231,10 +314,33 @@ export default function ImageManagement() {
function StatusBadge({ img }: { img: ImageInfo }) {
if (img.downloading) {
const progress = Math.max(0, Math.min(100, img.progress || 0))
const showProgress = img.stage === 'downloading' && progress > 0
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">
<span className="w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse" />
<div className="inline-flex flex-col gap-1">
<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>
)
}
@@ -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 {
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('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>
@@ -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('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('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
}
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 {
if (bytes <= 0) return '-'
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 { 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() {
const { login, accessCodeLogin } = useAuth()
const [username, setUsername] = useState('')
@@ -47,7 +40,7 @@ export default function Login() {
<div className="w-full max-w-md">
<div className="bg-white rounded-lg border border-gray-200 shadow-sm p-8">
<div className="flex flex-col items-center mb-8">
<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" />
</div>
<h1 className="text-2xl font-bold text-gray-950">CLICD</h1>
@@ -113,7 +106,7 @@ export default function Login() {
</form>
</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.7</p>
</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
}
+112 -24
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useState } from 'react'
import { Network, RefreshCw, Route, Server } from 'lucide-react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { RefreshCw, Search, Server, X } from 'lucide-react'
import { useNavigate } from 'react-router-dom'
import { getRoutingInfo, RoutingInfo } from '../services/api'
import { getRoutingInfo, RoutingInfo, NAT4Route, IPv6Route } from '../services/api'
export default function Routing() {
const navigate = useNavigate()
@@ -10,6 +10,8 @@ export default function Routing() {
const [refreshing, setRefreshing] = useState(false)
const [nat4Page, setNat4Page] = useState(1)
const [ipv6Page, setIPv6Page] = useState(1)
const [nat4Search, setNat4Search] = useState('')
const [ipv6Search, setIPv6Search] = useState('')
const fetchData = useCallback(async () => {
try {
@@ -25,6 +27,39 @@ export default function Routing() {
useEffect(() => { fetchData() }, [fetchData])
const nat4Mappings = routing?.nat4_mappings || []
const ipv6Assignments = routing?.ipv6_assignments || []
const ipv6Prefix = routing?.ipv6_prefixes?.[0]?.prefix || '-'
// Filter helpers
const matchesNat4Search = (m: NAT4Route, query: string) => {
if (!query) return true
const q = query.toLowerCase()
return (
String(m.host_port).includes(q) ||
String(m.container_port).includes(q) ||
m.container_name.toLowerCase().includes(q) ||
m.lxc_name.toLowerCase().includes(q) ||
(m.ip || '').toLowerCase().includes(q)
)
}
const matchesIPv6Search = (item: IPv6Route, query: string) => {
if (!query) return true
const q = query.toLowerCase()
return (
(item.address || '').toLowerCase().includes(q) ||
item.container_name.toLowerCase().includes(q) ||
item.lxc_name.toLowerCase().includes(q)
)
}
const filteredNat4 = useMemo(() => nat4Mappings.filter(m => matchesNat4Search(m, nat4Search)), [nat4Mappings, nat4Search])
const filteredIPv6 = useMemo(() => ipv6Assignments.filter(m => matchesIPv6Search(m, ipv6Search)), [ipv6Assignments, ipv6Search])
// Reset page on search change
useEffect(() => { setNat4Page(1) }, [nat4Search])
useEffect(() => { setIPv6Page(1) }, [ipv6Search])
if (loading) {
return (
<div className="flex items-center justify-center py-20">
@@ -33,16 +68,13 @@ export default function Routing() {
)
}
const nat4Mappings = routing?.nat4_mappings || []
const ipv6Assignments = routing?.ipv6_assignments || []
const ipv6Prefix = routing?.ipv6_prefixes?.[0]?.prefix || '-'
const pageSize = 10
const nat4TotalPages = Math.max(1, Math.ceil(nat4Mappings.length / pageSize))
const ipv6TotalPages = Math.max(1, Math.ceil(ipv6Assignments.length / pageSize))
const nat4TotalPages = Math.max(1, Math.ceil(filteredNat4.length / pageSize))
const ipv6TotalPages = Math.max(1, Math.ceil(filteredIPv6.length / pageSize))
const currentNat4Page = Math.min(nat4Page, nat4TotalPages)
const currentIPv6Page = Math.min(ipv6Page, ipv6TotalPages)
const pagedNat4Mappings = nat4Mappings.slice((currentNat4Page - 1) * pageSize, currentNat4Page * pageSize)
const pagedIPv6Assignments = ipv6Assignments.slice((currentIPv6Page - 1) * pageSize, currentIPv6Page * pageSize)
const pagedNat4Mappings = filteredNat4.slice((currentNat4Page - 1) * pageSize, currentNat4Page * pageSize)
const pagedIPv6Assignments = filteredIPv6.slice((currentIPv6Page - 1) * pageSize, currentIPv6Page * pageSize)
return (
<div className="space-y-5">
@@ -64,7 +96,7 @@ export default function Routing() {
<div className="grid gap-4 md:grid-cols-2">
<CapacityCard
title="NAT4 端口"
icon={<Route className="h-5 w-5 text-gray-600" />}
icon={<Nat4Icon />}
remaining={routing?.nat4.remaining || '0'}
total={routing?.nat4.total || '0'}
used={routing?.nat4.used || 0}
@@ -72,7 +104,7 @@ export default function Routing() {
/>
<CapacityCard
title="IPv6 地址"
icon={<Network className="h-5 w-5 text-gray-600" />}
icon={<IPv6Icon />}
remaining={formatCapacity(routing?.ipv6.remaining || '0')}
total={formatCapacity(routing?.ipv6.total || '0')}
used={routing?.ipv6.used || 0}
@@ -80,13 +112,32 @@ export default function Routing() {
/>
</div>
<div className="overflow-hidden rounded-lg border border-gray-200 bg-white">
<div className="border-b border-gray-200 px-4 py-3">
<div className="text-sm font-medium text-black">NAT4 </div>
<div className="mt-1 text-xs text-gray-500"> {nat4Mappings.length} </div>
<div className="overflow-hidden rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900">
<div className="border-b border-gray-200 dark:border-gray-700 px-4 py-3 flex items-center justify-between gap-3">
<div>
<div className="text-sm font-medium text-black dark:text-white">NAT4 </div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{nat4Search ? `搜索 "${nat4Search}" 结果 ${filteredNat4.length} 条,` : ''} {nat4Mappings.length}
</div>
</div>
<div className="relative w-48">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-gray-400" />
<input
type="text"
value={nat4Search}
onChange={e => setNat4Search(e.target.value)}
placeholder="搜索端口/容器..."
className="w-full pl-8 pr-7 py-1.5 text-xs border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-black dark:text-white focus:outline-none focus:ring-1 focus:ring-black dark:focus:ring-white"
/>
{nat4Search && (
<button onClick={() => setNat4Search('')} className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
<X className="w-3 h-3" />
</button>
)}
</div>
</div>
{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">
@@ -130,7 +181,7 @@ export default function Routing() {
<Pagination
page={currentNat4Page}
totalPages={nat4TotalPages}
totalItems={nat4Mappings.length}
totalItems={filteredNat4.length}
pageSize={pageSize}
onPageChange={setNat4Page}
/>
@@ -138,13 +189,32 @@ export default function Routing() {
)}
</div>
<div className="overflow-hidden rounded-lg border border-gray-200 bg-white">
<div className="border-b border-gray-200 px-4 py-3">
<div className="text-sm font-medium text-black">IPv6 </div>
<div className="mt-1 text-xs text-gray-500"> {ipv6Assignments.length} </div>
<div className="overflow-hidden rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900">
<div className="border-b border-gray-200 dark:border-gray-700 px-4 py-3 flex items-center justify-between gap-3">
<div>
<div className="text-sm font-medium text-black dark:text-white">IPv6 </div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{ipv6Search ? `搜索 "${ipv6Search}" 结果 ${filteredIPv6.length} 条,` : ''} {ipv6Assignments.length}
</div>
</div>
<div className="relative w-48">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-gray-400" />
<input
type="text"
value={ipv6Search}
onChange={e => setIPv6Search(e.target.value)}
placeholder="搜索地址/容器..."
className="w-full pl-8 pr-7 py-1.5 text-xs border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-black dark:text-white focus:outline-none focus:ring-1 focus:ring-black dark:focus:ring-white"
/>
{ipv6Search && (
<button onClick={() => setIPv6Search('')} className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
<X className="w-3 h-3" />
</button>
)}
</div>
</div>
{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">
@@ -184,7 +254,7 @@ export default function Routing() {
<Pagination
page={currentIPv6Page}
totalPages={ipv6TotalPages}
totalItems={ipv6Assignments.length}
totalItems={filteredIPv6.length}
pageSize={pageSize}
onPageChange={setIPv6Page}
/>
@@ -287,3 +357,21 @@ function formatCapacity(value: string): string {
if (value === 'large') return '充足'
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 { RefreshCw } from 'lucide-react'
import { getSecurityAlerts, SecurityAlert } from '../services/api'
import { FileText, Power, RefreshCw, X } from 'lucide-react'
import { getSecurityAlerts, getSecurityLogs, getSecuritySettings, SecurityAlert, SecurityLog, updateSecuritySettings } from '../services/api'
const typeLabels: Record<string, string> = {
port_scan: '端口扫描',
@@ -23,12 +23,18 @@ const severityLabels: Record<string, string> = {
export default function Security() {
const [alerts, setAlerts] = useState<SecurityAlert[]>([])
const [autoShutdown, setAutoShutdown] = useState(false)
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 () => {
try {
const alertRes = await getSecurityAlerts()
const [alertRes, settingsRes] = await Promise.all([getSecurityAlerts(), getSecuritySettings()])
if (alertRes.data.data) setAlerts(alertRes.data.data)
if (settingsRes.data.data) setAutoShutdown(settingsRes.data.data.auto_shutdown)
} catch (err) {
console.error(err)
} finally {
@@ -42,6 +48,36 @@ export default function Security() {
return () => clearInterval(interval)
}, [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) {
return (
<div className="flex items-center justify-center py-20">
@@ -52,15 +88,33 @@ export default function Security() {
return (
<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>
<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 className="flex flex-wrap items-center gap-2">
<button
type="button"
role="switch"
aria-checked={autoShutdown}
onClick={handleAutoShutdownChange}
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 className="bg-white border border-gray-200 rounded-lg overflow-hidden">
@@ -93,12 +147,24 @@ export default function Security() {
</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-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">
{formatTarget(alert)}
</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>
))}
</tbody>
@@ -106,6 +172,69 @@ export default function Security() {
</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>
)
}
@@ -129,3 +258,17 @@ function formatTarget(alert: SecurityAlert): string {
if (!alert.target_ip) return '-'
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
}
+332 -98
View File
@@ -1,10 +1,13 @@
import { useState, useEffect, useCallback } from 'react'
import { UserCog, Key, LogIn, Monitor, Clock, Globe } from 'lucide-react'
import { Dispatch, SetStateAction, useCallback, useEffect, useState } from 'react'
import { Clock, Globe, Lock, LogIn, Monitor, RefreshCw, ShieldCheck, Upload, UserCog } from 'lucide-react'
import {
changePassword,
changeUsername,
getLoginLogs,
getSSLSettings,
LoginLog,
SSLSettings,
updateSSLSettings,
} from '../services/api'
import { useDialog } from '../components/Dialog'
import { useAuth } from '../contexts/AuthContext'
@@ -20,7 +23,16 @@ export default function Settings() {
const [oldPwd, setOldPwd] = useState('')
const [newPwd, setNewPwd] = useState('')
const [newUsername, setNewUsername] = useState('')
const [pwdForUser, setPwdForUser] = useState('')
const [ssl, setSSL] = useState<SSLSettings | null>(null)
const [sslEnabled, setSSLEnabled] = useState(false)
const [sslMode, setSSLMode] = useState<SSLSettings['mode']>('disabled')
const [sslTarget, setSSLTarget] = useState('')
const [sslEmail, setSSLEmail] = useState('')
const [certPEM, setCertPEM] = useState('')
const [keyPEM, setKeyPEM] = useState('')
const [applyNow, setApplyNow] = useState(true)
const [savingSSL, setSavingSSL] = useState(false)
const fetchLogs = useCallback(async () => {
try {
@@ -33,30 +45,95 @@ export default function Settings() {
}
}, [])
useEffect(() => { fetchLogs(); const t = setInterval(fetchLogs, 15000); return () => clearInterval(t) }, [fetchLogs])
const fetchSSL = useCallback(async () => {
try {
const res = await getSSLSettings()
const data = res.data.data
if (!data) return
setSSL(data)
setSSLEnabled(data.enabled)
setSSLMode(data.mode || 'disabled')
setSSLTarget(data.target || data.detected_host || '')
setSSLEmail(data.email || '')
} catch (err) {
console.error(err)
}
}, [])
useEffect(() => {
fetchLogs()
fetchSSL()
const timer = setInterval(fetchLogs, 15000)
return () => clearInterval(timer)
}, [fetchLogs, fetchSSL])
const handleSSLModeChange = (mode: SSLSettings['mode']) => {
setSSLMode(mode)
const saved = ssl?.mode_certificates?.[mode]
setSSLTarget(saved?.target || ssl?.detected_host || sslTarget)
setSSLEmail(saved?.email || '')
}
const handleSaveSSL = async () => {
setSavingSSL(true)
try {
const enabled = sslEnabled && sslMode !== 'disabled'
const res = await updateSSLSettings({
enabled,
mode: enabled ? sslMode : 'disabled',
target: sslTarget,
email: sslEmail,
cert_pem: certPEM,
key_pem: keyPEM,
apply_now: applyNow,
})
if (res.data.data) {
setSSL(res.data.data)
setCertPEM('')
setKeyPEM('')
}
dialog.alert('完成', applyNow ? 'SSL 设置已保存,服务正在重启。稍后请用新的协议重新打开面板。' : 'SSL 设置已保存,重启 clicd 服务后生效。')
} catch (err: unknown) {
const e = err as { response?: { data?: { message?: string } } }
dialog.alert('失败', e.response?.data?.message || 'SSL 设置保存失败')
} finally {
setSavingSSL(false)
}
}
const handleSaveAccount = async () => {
if (!oldPwd) { dialog.alert('提示', '请输入当前密码以确认修改'); return }
if (!newPwd && !newUsername) { dialog.alert('提示', '至少填写新密码或新用户名中的一项'); return }
if (newPwd && newPwd.length < 6) { dialog.alert('提示', '新密码至少 6 位'); return }
if (newUsername && newUsername.length < 3) { dialog.alert('提示', '用户名至少 3 位'); return }
if (!oldPwd) {
dialog.alert('提示', '请输入当前密码以确认修改')
return
}
if (!newPwd && !newUsername) {
dialog.alert('提示', '至少填写新密码或新用户名中的一项')
return
}
if (newPwd && newPwd.length < 6) {
dialog.alert('提示', '新密码至少 6 位')
return
}
if (newUsername && newUsername.length < 3) {
dialog.alert('提示', '用户名至少 3 位')
return
}
let results: string[] = []
const results: string[] = []
try {
// 先改用户名(用旧密码验证),再改密码,否则改完密码后旧密码就失效了
if (newUsername) {
const res = await changeUsername(newUsername, oldPwd)
if (res.data.success) results.push('用户名已修改')
else results.push('用户名修改失败')
results.push(res.data.success ? '用户名已修改' : '用户名修改失败')
}
if (newPwd) {
const res = await changePassword(oldPwd, newPwd)
if (res.data.success) results.push('密码已修改')
else results.push('密码修改失败')
results.push(res.data.success ? '密码已修改' : '密码修改失败')
}
if (results.length > 0) {
dialog.alert('完成', results.join('') + '。下次登录生效')
setOldPwd(''); setNewPwd(''); setNewUsername('')
dialog.alert('完成', `${results.join('')}。下次登录生效`)
setOldPwd('')
setNewPwd('')
setNewUsername('')
}
} catch (err: unknown) {
const e = err as { response?: { data?: { message?: string } } }
@@ -67,111 +144,268 @@ export default function Settings() {
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 className="h-8 w-8 animate-spin rounded-full border-b-2 border-black"></div>
</div>
)
}
const totalPages = Math.ceil(logs.length / pageSize)
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-black"></h1>
<p className="text-sm text-gray-500 mt-1"></p>
<p className="mt-1 text-sm text-gray-500"></p>
</div>
{/* Account Settings */}
<div className="bg-white border border-gray-200 rounded-lg p-5">
<h2 className="text-sm font-semibold text-black mb-4 flex items-center gap-2">
<UserCog className="w-4 h-4" />
</h2>
<div className="space-y-4">
<div>
<label className="block text-xs text-gray-500 mb-1"></label>
<input type="text" value={username || ''} disabled className="w-full px-3 py-2 border border-gray-200 rounded-md text-sm text-gray-400 bg-gray-50" />
<div className="grid items-start gap-6 xl:grid-cols-[minmax(0,1.15fr)_minmax(360px,0.85fr)]">
<SSLCard
ssl={ssl}
sslEnabled={sslEnabled}
sslMode={sslMode}
sslTarget={sslTarget}
sslEmail={sslEmail}
certPEM={certPEM}
keyPEM={keyPEM}
applyNow={applyNow}
savingSSL={savingSSL}
onRefresh={fetchSSL}
onEnabledChange={setSSLEnabled}
onModeChange={handleSSLModeChange}
onTargetChange={setSSLTarget}
onEmailChange={setSSLEmail}
onCertChange={setCertPEM}
onKeyChange={setKeyPEM}
onApplyNowChange={setApplyNow}
onSave={handleSaveSSL}
/>
<div className="rounded-lg border border-gray-200 bg-white p-5">
<h2 className="mb-4 flex items-center gap-2 text-sm font-semibold text-black">
<UserCog className="h-4 w-4" />
</h2>
<div className="space-y-4">
<div>
<label className="mb-1 block text-xs text-gray-500"></label>
<input type="text" value={username || ''} disabled className="w-full rounded-md border border-gray-200 bg-gray-50 px-3 py-2 text-sm text-gray-400" />
</div>
<div>
<label className="mb-1 block text-xs text-gray-500"></label>
<input type="text" value={newUsername} onChange={(e) => setNewUsername(e.target.value)} className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black" placeholder="至少 3 位" />
</div>
<div className="border-t border-gray-100 pt-3">
<label className="mb-1 block text-xs text-gray-500"></label>
<input type="password" value={newPwd} onChange={(e) => setNewPwd(e.target.value)} className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black" placeholder="至少 6 位" />
</div>
<div>
<label className="mb-1 block text-xs text-gray-500"></label>
<input type="password" value={oldPwd} onChange={(e) => setOldPwd(e.target.value)} className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black" placeholder="输入当前密码以确认修改" />
</div>
<button onClick={handleSaveAccount} className="w-full rounded-md bg-black px-4 py-2 text-sm text-white hover:bg-gray-800"></button>
</div>
<div>
<label className="block text-xs text-gray-500 mb-1"></label>
<input type="text" value={newUsername} onChange={(e) => setNewUsername(e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm text-black bg-white" placeholder="至少 3 位" />
</div>
<div className="border-t border-gray-100 pt-3">
<label className="block text-xs text-gray-500 mb-1"></label>
<input type="password" value={newPwd} onChange={(e) => setNewPwd(e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm text-black bg-white" placeholder="至少 6 位" />
</div>
<div>
<label className="block text-xs text-gray-500 mb-1"></label>
<input type="password" value={oldPwd} onChange={(e) => setOldPwd(e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm text-black bg-white" placeholder="输入当前密码以确认修改" />
</div>
<button onClick={handleSaveAccount} className="w-full px-4 py-2 bg-black text-white rounded-md text-sm hover:bg-gray-800"></button>
</div>
</div>
{/* Login Logs */}
<div className="bg-white border border-gray-200 rounded-lg p-5">
<h2 className="text-sm font-semibold text-black mb-4 flex items-center gap-2">
<LogIn className="w-4 h-4" />
<LoginLogCard logs={logs} logPage={logPage} pageSize={pageSize} totalPages={totalPages} setLogPage={setLogPage} />
</div>
)
}
interface SSLCardProps {
ssl: SSLSettings | null
sslEnabled: boolean
sslMode: SSLSettings['mode']
sslTarget: string
sslEmail: string
certPEM: string
keyPEM: string
applyNow: boolean
savingSSL: boolean
onRefresh: () => void
onEnabledChange: (enabled: boolean) => void
onModeChange: (mode: SSLSettings['mode']) => void
onTargetChange: (target: string) => void
onEmailChange: (email: string) => void
onCertChange: (cert: string) => void
onKeyChange: (key: string) => void
onApplyNowChange: (apply: boolean) => void
onSave: () => void
}
function SSLCard(props: SSLCardProps) {
const selectedSSL = props.ssl?.mode_certificates?.[props.sslMode]
const modeOptions: Array<{ value: SSLSettings['mode']; label: string }> = [
{ value: 'letsencrypt', label: 'Lets Encrypt' },
{ value: 'self_signed', label: '自签证书' },
{ value: 'uploaded', label: '上传证书' },
]
return (
<div className="rounded-lg border border-gray-200 bg-white p-5">
<div className="mb-4 flex items-center justify-between gap-3">
<h2 className="flex items-center gap-2 text-sm font-semibold text-black">
<ShieldCheck className="h-4 w-4" />SSL
</h2>
{logs.length === 0 ? (
<p className="text-sm text-gray-400"></p>
) : (
<>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="text-gray-400 border-b border-gray-100">
<th className="text-left py-2 font-medium w-40"><span className="inline-flex items-center gap-1"><Clock className="w-3 h-3" /></span></th>
<th className="text-left py-2 font-medium"></th>
<th className="text-left py-2 font-medium"><span className="inline-flex items-center gap-1"><Globe className="w-3 h-3" />IP</span></th>
<th className="text-left py-2 font-medium"><span className="inline-flex items-center gap-1"><Monitor className="w-3 h-3" /></span></th>
<th className="text-left py-2 font-medium"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-50">
{logs.slice((logPage - 1) * pageSize, logPage * pageSize).map((log, i) => (
<tr key={i}>
<td className="py-1.5 text-gray-500 font-mono whitespace-nowrap">{log.time}</td>
<td className="py-1.5 text-gray-700">{log.username}</td>
<td className="py-1.5 text-gray-500 font-mono">{log.ip}</td>
<td className="py-1.5 text-gray-500 max-w-[180px] truncate" title={log.user_agent}>{formatUA(log.user_agent)}</td>
<td className="py-1.5">
<span className={`px-1.5 py-0.5 rounded text-xs ${log.success ? 'bg-gray-100 text-gray-700' : 'bg-red-50 text-red-600'}`}>
{log.success ? '成功' : '失败'}
</span>
</td>
</tr>
))}
</tbody>
</table>
<button onClick={props.onRefresh} className="rounded-md border border-gray-200 p-1.5 text-gray-500 hover:bg-gray-50" title="刷新">
<RefreshCw className="h-4 w-4" />
</button>
</div>
<div className="space-y-4">
<label className="flex items-center gap-2 text-sm text-gray-700">
<input type="checkbox" checked={props.sslEnabled} onChange={(e) => props.onEnabledChange(e.target.checked)} className="h-4 w-4 rounded border-gray-300" />
HTTPS / WSS
</label>
<div className="grid gap-2 sm:grid-cols-3">
{modeOptions.map((option) => (
<button
key={option.value}
onClick={() => props.onModeChange(option.value)}
className={`rounded-md border px-3 py-2 text-sm ${props.sslMode === option.value ? 'border-black bg-black text-white' : 'border-gray-200 text-gray-700 hover:bg-gray-50'}`}
>
{option.label}
</button>
))}
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div>
<label className="mb-1 block text-xs text-gray-500">IP / </label>
<input
type="text"
value={props.sslTarget}
onChange={(e) => props.onTargetChange(e.target.value)}
className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black"
placeholder={props.ssl?.detected_host || '服务器公网 IP 或域名'}
/>
</div>
{props.sslMode === 'letsencrypt' && (
<div>
<label className="mb-1 block text-xs text-gray-500"></label>
<input type="email" value={props.sslEmail} onChange={(e) => props.onEmailChange(e.target.value)} className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black" placeholder="admin@example.com" />
</div>
{logs.length > pageSize && (
<div className="flex items-center justify-between mt-3 pt-3 border-t border-gray-100">
<span className="text-xs text-gray-400"> {logs.length} {logPage}/{Math.ceil(logs.length / pageSize)} </span>
<div className="flex items-center gap-1">
<button onClick={() => setLogPage(1)} disabled={logPage === 1} className="px-2 py-1 text-xs border border-gray-200 rounded hover:bg-gray-50 disabled:opacity-30"></button>
<button onClick={() => setLogPage(p => Math.max(1, p - 1))} disabled={logPage === 1} className="px-2 py-1 text-xs border border-gray-200 rounded hover:bg-gray-50 disabled:opacity-30"></button>
{Array.from({length: Math.min(5, Math.ceil(logs.length / pageSize))}, (_, i) => {
const totalPages = Math.ceil(logs.length / pageSize)
let start = Math.max(1, logPage - 2)
if (start + 4 > totalPages) start = Math.max(1, totalPages - 4)
const page = start + i
if (page > totalPages) return null
return (
<button key={page} onClick={() => setLogPage(page)} className={`w-7 h-7 text-xs rounded ${page === logPage ? 'bg-black text-white' : 'border border-gray-200 hover:bg-gray-50'}`}>{page}</button>
)
})}
<button onClick={() => setLogPage(p => Math.min(Math.ceil(logs.length / pageSize), p + 1))} disabled={logPage >= Math.ceil(logs.length / pageSize)} className="px-2 py-1 text-xs border border-gray-200 rounded hover:bg-gray-50 disabled:opacity-30"></button>
<button onClick={() => setLogPage(Math.ceil(logs.length / pageSize))} disabled={logPage >= Math.ceil(logs.length / pageSize)} className="px-2 py-1 text-xs border border-gray-200 rounded hover:bg-gray-50 disabled:opacity-30"></button>
</div>
</div>
)}
</>
)}
</div>
{props.sslMode === 'letsencrypt' && (
<div className="rounded-md border border-amber-200 bg-amber-50 p-3 text-xs text-amber-800">
IP Certbot 5.4+ 80 Lets Encrypt 访IP certbot
</div>
)}
{props.sslMode === 'self_signed' && (
<div className="rounded-md border border-gray-100 bg-gray-50 p-3 text-xs text-gray-600">
VNC
</div>
)}
{props.sslMode === 'uploaded' && (
<div className="grid gap-3 lg:grid-cols-2">
<div>
<label className="mb-1 block text-xs text-gray-500"> PEM / fullchain.pem</label>
<textarea value={props.certPEM} onChange={(e) => props.onCertChange(e.target.value)} rows={7} className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 font-mono text-xs text-black" placeholder="-----BEGIN CERTIFICATE-----" />
</div>
<div>
<label className="mb-1 block text-xs text-gray-500"> PEM / privkey.pem</label>
<textarea value={props.keyPEM} onChange={(e) => props.onKeyChange(e.target.value)} rows={7} className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 font-mono text-xs text-black" placeholder="-----BEGIN PRIVATE KEY-----" />
</div>
</div>
)}
{selectedSSL?.certificate ? (
<div className="rounded-md border border-gray-100 bg-gray-50 p-3 text-xs text-gray-600">
<div className="flex items-center gap-2 text-gray-800">
<Lock className="h-3.5 w-3.5" />
{selectedSSL.certificate.valid ? '有效' : '已过期或未生效'}
</div>
<div className="mt-1 font-mono">{selectedSSL.certificate.not_after}</div>
<div className="mt-1 truncate font-mono" title={selectedSSL.cert_path}>{selectedSSL.cert_path || '-'}</div>
{selectedSSL.last_error && <div className="mt-1 text-red-600">{selectedSSL.last_error}</div>}
</div>
) : (
<div className="rounded-md border border-gray-100 bg-gray-50 p-3 text-xs text-gray-600">
{props.sslMode === 'uploaded' ? '上传来源还没有保存证书,请粘贴证书和私钥后保存。' : '当前来源还没有保存证书,保存 SSL 设置时会自动生成或申请。'}
</div>
)}
<label className="flex items-center gap-2 text-xs text-gray-500">
<input type="checkbox" checked={props.applyNow} onChange={(e) => props.onApplyNowChange(e.target.checked)} className="h-4 w-4 rounded border-gray-300" />
</label>
<button onClick={props.onSave} disabled={props.savingSSL} className="inline-flex w-full items-center justify-center gap-2 rounded-md bg-black px-4 py-2 text-sm text-white hover:bg-gray-800 disabled:opacity-50">
<Upload className="h-4 w-4" />
{props.savingSSL ? '保存中...' : '保存 SSL 设置'}
</button>
</div>
</div>
)
}
interface LoginLogCardProps {
logs: LoginLog[]
logPage: number
pageSize: number
totalPages: number
setLogPage: Dispatch<SetStateAction<number>>
}
function LoginLogCard({ logs, logPage, pageSize, totalPages, setLogPage }: LoginLogCardProps) {
return (
<div className="rounded-lg border border-gray-200 bg-white p-5">
<h2 className="mb-4 flex items-center gap-2 text-sm font-semibold text-black">
<LogIn className="h-4 w-4" />
</h2>
{logs.length === 0 ? (
<p className="text-sm text-gray-400"></p>
) : (
<>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-gray-100 text-gray-400">
<th className="w-40 py-2 text-left font-medium"><span className="inline-flex items-center gap-1"><Clock className="h-3 w-3" /></span></th>
<th className="py-2 text-left font-medium"></th>
<th className="py-2 text-left font-medium"><span className="inline-flex items-center gap-1"><Globe className="h-3 w-3" />IP</span></th>
<th className="py-2 text-left font-medium"><span className="inline-flex items-center gap-1"><Monitor className="h-3 w-3" /></span></th>
<th className="py-2 text-left font-medium"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-50">
{logs.slice((logPage - 1) * pageSize, logPage * pageSize).map((log, index) => (
<tr key={`${log.time}-${index}`}>
<td className="whitespace-nowrap py-1.5 font-mono text-gray-500">{log.time}</td>
<td className="py-1.5 text-gray-700">{log.username}</td>
<td className="py-1.5 font-mono text-gray-500">{log.ip}</td>
<td className="max-w-[180px] truncate py-1.5 text-gray-500" title={log.user_agent}>{formatUA(log.user_agent)}</td>
<td className="py-1.5">
<span className={`rounded px-1.5 py-0.5 text-xs ${log.success ? 'bg-gray-100 text-gray-700' : 'bg-red-50 text-red-600'}`}>
{log.success ? '成功' : '失败'}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
{logs.length > pageSize && (
<div className="mt-3 flex items-center justify-between border-t border-gray-100 pt-3">
<span className="text-xs text-gray-400"> {logs.length} {logPage}/{totalPages} </span>
<div className="flex items-center gap-1">
<button onClick={() => setLogPage(1)} disabled={logPage === 1} className="rounded border border-gray-200 px-2 py-1 text-xs hover:bg-gray-50 disabled:opacity-30"></button>
<button onClick={() => setLogPage(p => Math.max(1, p - 1))} disabled={logPage === 1} className="rounded border border-gray-200 px-2 py-1 text-xs hover:bg-gray-50 disabled:opacity-30"></button>
<button onClick={() => setLogPage(p => Math.min(totalPages, p + 1))} disabled={logPage >= totalPages} className="rounded border border-gray-200 px-2 py-1 text-xs hover:bg-gray-50 disabled:opacity-30"></button>
<button onClick={() => setLogPage(totalPages)} disabled={logPage >= totalPages} className="rounded border border-gray-200 px-2 py-1 text-xs hover:bg-gray-50 disabled:opacity-30"></button>
</div>
</div>
)}
</>
)}
</div>
)
}
function formatUA(ua: string): string {
// Extract browser/OS info from UA string
const parts: string[] = []
if (ua.includes('Windows NT')) parts.push('Windows')
else if (ua.includes('Mac OS X')) parts.push('macOS')
+35 -2
View File
@@ -1,13 +1,16 @@
import { useCallback, useEffect, useState } from 'react'
import { Camera, RefreshCw, Server } from 'lucide-react'
import { Camera, RefreshCw, Server, Trash2 } from 'lucide-react'
import { useNavigate } from 'react-router-dom'
import { getSnapshots, Snapshot } from '../services/api'
import { deleteContainerSnapshot, getSnapshots, Snapshot } from '../services/api'
import { useDialog } from '../components/Dialog'
export default function Snapshots() {
const navigate = useNavigate()
const dialog = useDialog()
const [snapshots, setSnapshots] = useState<Snapshot[]>([])
const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false)
const [deleting, setDeleting] = useState<string | null>(null)
const fetchData = useCallback(async () => {
try {
@@ -23,6 +26,25 @@ export default function Snapshots() {
useEffect(() => { fetchData() }, [fetchData])
const handleDelete = async (snapshot: Snapshot) => {
const confirmed = await dialog.confirm(
'删除快照',
`确认删除容器 ${snapshot.container_name} 的快照吗?此操作不可恢复。`
)
if (!confirmed) return
setDeleting(snapshot.id)
try {
await deleteContainerSnapshot(snapshot.container_id, snapshot.id)
setSnapshots(prev => prev.filter(s => s.id !== snapshot.id))
} catch (err: unknown) {
const error = err as { response?: { data?: { message?: string } } }
dialog.alert('删除失败', error.response?.data?.message || '请稍后重试')
} finally {
setDeleting(null)
}
}
if (loading) {
return (
<div className="flex items-center justify-center py-20">
@@ -66,6 +88,7 @@ export default function Snapshots() {
<th className="px-4 py-3 text-left font-medium"></th>
<th className="px-4 py-3 text-left font-medium"></th>
<th className="px-4 py-3 text-right font-medium"></th>
<th className="px-4 py-3 text-center font-medium"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
@@ -89,6 +112,16 @@ export default function Snapshots() {
</td>
<td className="px-4 py-3 text-gray-600">{snapshot.created_by || '-'}</td>
<td className="px-4 py-3 text-right font-mono text-xs text-gray-600">{formatBytes(snapshot.size_bytes || 0)}</td>
<td className="px-4 py-3 text-center">
<button
onClick={() => handleDelete(snapshot)}
disabled={deleting === snapshot.id}
className="inline-flex items-center justify-center p-1.5 rounded text-red-500 hover:bg-red-50 transition-colors disabled:opacity-50"
title="删除快照"
>
<Trash2 className="h-4 w-4" />
</button>
</td>
</tr>
))}
</tbody>
+367
View File
@@ -0,0 +1,367 @@
import { useCallback, useEffect, useState } from 'react'
import { Copy, KeyRound, LogIn, RefreshCw, ScrollText, UserCog, X } from 'lucide-react'
import { useDialog } from '../components/Dialog'
import api, { AuditLog, LoginLog } from '../services/api'
import { copyToClipboard } from '../utils/clipboard'
interface SubUserItem {
id: string
username: string
container_names: string[]
container_uuids: string[]
container_name: string
container_uuid: string
access_code: string
password?: string
created_at: string
last_login: string
last_login_ip: string
last_login_ua: string
}
interface AuditLogExt extends AuditLog {
ip?: string
user_agent?: string
success?: boolean
error?: string
}
export default function SubUserManagement() {
const dialog = useDialog()
const [users, setUsers] = useState<SubUserItem[]>([])
const [loading, setLoading] = useState(true)
const [auditLogs, setAuditLogs] = useState<AuditLogExt[] | null>(null)
const [loginLogs, setLoginLogs] = useState<LoginLog[] | null>(null)
const [modalTitle, setModalTitle] = useState('')
const [passwordUser, setPasswordUser] = useState<SubUserItem | null>(null)
const [rotatingPassword, setRotatingPassword] = useState(false)
const [logPage, setLogPage] = useState(1)
const [logPageSize, setLogPageSize] = useState(10)
const fetchUsers = useCallback(async () => {
try {
const res = await api.get<{ success: boolean; data: SubUserItem[] }>('/sub-users')
setUsers(res.data.data || [])
} catch (err) {
console.error(err)
} finally {
setLoading(false)
}
}, [])
useEffect(() => { fetchUsers() }, [fetchUsers])
const managementUrl = (user: SubUserItem) => `${window.location.origin}/login?code=${user.access_code}`
const copyText = async (text: string) => {
await copyToClipboard(text)
}
const rotatePassword = async (user: SubUserItem) => {
setRotatingPassword(true)
try {
const res = await api.post(`/sub-users/${user.id}/rotate-password`)
const data = res.data.data
const updatedUser = {
...user,
username: data?.username || user.username,
access_code: data?.access_code || user.access_code,
password: data?.password || '',
}
setUsers((prev) => prev.map((item) => (item.id === user.id ? updatedUser : item)))
setPasswordUser(updatedUser)
} catch (err: unknown) {
const error = err as { response?: { data?: { message?: string } } }
dialog.alert('轮换失败', error.response?.data?.message || '请稍后重试')
} finally {
setRotatingPassword(false)
}
}
const showAuditLogs = async (user: SubUserItem) => {
try {
const res = await api.get(`/sub-users/${user.id}/audit-logs`)
setAuditLogs(res.data.data || [])
setLoginLogs(null)
setModalTitle(`${user.username} - 操作日志`)
setLogPage(1)
} catch {
dialog.alert('错误', '获取操作日志失败')
}
}
const showLoginLogs = async (user: SubUserItem) => {
try {
const res = await api.get(`/sub-users/${user.id}/login-logs`)
setLoginLogs(res.data.data || [])
setAuditLogs(null)
setModalTitle(`${user.username} - 登录日志`)
setLogPage(1)
} catch {
dialog.alert('错误', '获取登录日志失败')
}
}
const closeModal = () => {
setAuditLogs(null)
setLoginLogs(null)
}
const currentLogTotal = auditLogs?.length ?? loginLogs?.length ?? 0
const logTotalPages = Math.max(1, Math.ceil(currentLogTotal / logPageSize))
const currentLogPage = Math.min(logPage, logTotalPages)
const logStart = (currentLogPage - 1) * logPageSize
const currentAuditLogs = auditLogs?.slice(logStart, logStart + logPageSize)
const currentLoginLogs = loginLogs?.slice(logStart, logStart + logPageSize)
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<div className="h-8 w-8 animate-spin rounded-full border-b-2 border-black" />
</div>
)
}
return (
<div className="space-y-5">
<div>
<h1 className="text-xl font-semibold text-black dark:text-white"></h1>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400"> {users.length} </p>
</div>
<div className="overflow-hidden rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900">
{users.length === 0 ? (
<div className="flex flex-col items-center justify-center px-6 py-16 text-center">
<div className="mb-4 flex h-14 w-14 items-center justify-center rounded-lg bg-gray-100 dark:bg-gray-800">
<UserCog className="h-7 w-7 text-gray-400" />
</div>
<div className="text-sm font-medium text-gray-700 dark:text-gray-300"></div>
</div>
) : (
<table className="w-full min-w-[820px] text-sm">
<thead className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 text-xs text-gray-500 dark:text-gray-400">
<tr>
<th className="px-4 py-3 text-left font-medium w-12">#</th>
<th className="px-4 py-3 text-left font-medium"></th>
<th className="px-4 py-3 text-left font-medium">UUID</th>
<th className="px-4 py-3 text-left font-medium"></th>
<th className="px-4 py-3 text-center font-medium"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
{users.map((user, index) => (
<tr key={user.id} className="hover:bg-gray-50 dark:hover:bg-gray-800">
<td className="px-4 py-3 text-gray-400 dark:text-gray-500">{index + 1}</td>
<td className="px-4 py-3 font-medium text-black dark:text-white">{user.container_name || '-'}</td>
<td className="px-4 py-3 font-mono text-xs text-gray-600 dark:text-gray-400">{user.container_uuid || '-'}</td>
<td className="px-4 py-3 text-gray-600 dark:text-gray-400">
{user.last_login ? (
<div>
<div className="text-xs">{user.last_login}</div>
<div className="text-xs text-gray-400 dark:text-gray-500">{user.last_login_ip}</div>
</div>
) : (
<span className="text-gray-400"></span>
)}
</td>
<td className="px-4 py-3">
<div className="flex items-center justify-center gap-1">
<button
onClick={() => setPasswordUser(user)}
className="inline-flex items-center gap-1 px-2 py-1.5 rounded text-xs text-amber-600 hover:bg-amber-50 dark:hover:bg-amber-900/30 transition-colors"
title="查看密码"
>
<KeyRound className="w-3.5 h-3.5" />
</button>
<button
onClick={() => showAuditLogs(user)}
className="inline-flex items-center gap-1 px-2 py-1.5 rounded text-xs text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/30 transition-colors"
title="查看操作日志"
>
<ScrollText className="w-3.5 h-3.5" />
</button>
<button
onClick={() => showLoginLogs(user)}
className="inline-flex items-center gap-1 px-2 py-1.5 rounded text-xs text-green-600 hover:bg-green-50 dark:hover:bg-green-900/30 transition-colors"
title="查看登录日志"
>
<LogIn className="w-3.5 h-3.5" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
{passwordUser && (
<div className="fixed inset-0 bg-black/50 dark:bg-black/70 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700 shadow-xl w-full max-w-lg overflow-hidden">
<div className="flex items-center justify-between gap-3 px-5 py-3 border-b border-gray-200 dark:border-gray-700">
<h3 className="text-sm font-semibold text-black dark:text-white"></h3>
<div className="flex items-center gap-2">
<button
onClick={() => rotatePassword(passwordUser)}
disabled={rotatingPassword}
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded text-xs text-amber-700 bg-amber-50 hover:bg-amber-100 dark:text-amber-300 dark:bg-amber-900/30 dark:hover:bg-amber-900/50 disabled:opacity-50"
title="轮换密码"
>
<RefreshCw className={`w-3.5 h-3.5 ${rotatingPassword ? 'animate-spin' : ''}`} />
{rotatingPassword ? '轮换中...' : '轮换密码'}
</button>
<button onClick={() => setPasswordUser(null)} className="p-1 text-gray-400 hover:text-black dark:hover:text-white rounded">
<X className="w-4 h-4" />
</button>
</div>
</div>
<div className="p-5">
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 text-sm space-y-3">
<div className="flex items-start justify-between gap-3">
<span className="shrink-0 text-gray-500 dark:text-gray-400"></span>
<span className="min-w-0 text-right font-medium text-black dark:text-white break-all">{passwordUser.username}</span>
</div>
<div className="flex items-start justify-between gap-3">
<span className="shrink-0 text-gray-500 dark:text-gray-400"></span>
<div className="flex min-w-0 items-center gap-1">
<span className="font-mono text-xs text-black dark:text-white break-all">{managementUrl(passwordUser)}</span>
<button onClick={() => copyText(managementUrl(passwordUser))} className="shrink-0 p-0.5 text-gray-400 hover:text-black dark:hover:text-white rounded" title="复制">
<Copy className="w-3 h-3" />
</button>
</div>
</div>
<div className="flex items-start justify-between gap-3">
<span className="shrink-0 text-gray-500 dark:text-gray-400"></span>
<div className="flex min-w-0 items-center gap-1">
<span className="font-mono text-xs text-black dark:text-white break-all">
{passwordUser.password || '未保存,请轮换生成新密码'}
</span>
{passwordUser.password && (
<button onClick={() => copyText(passwordUser.password || '')} className="shrink-0 p-0.5 text-gray-400 hover:text-black dark:hover:text-white rounded" title="复制">
<Copy className="w-3 h-3" />
</button>
)}
</div>
</div>
</div>
</div>
</div>
</div>
)}
{/* Log Modal */}
{(auditLogs || loginLogs) && (
<div className="fixed inset-0 bg-black/50 dark:bg-black/70 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700 shadow-xl w-full max-w-3xl max-h-[85vh] overflow-hidden flex flex-col">
<div className="flex items-center justify-between px-5 py-3 border-b border-gray-200 dark:border-gray-700">
<h3 className="text-sm font-semibold text-black dark:text-white">{modalTitle}</h3>
<button onClick={closeModal} className="p-1 text-gray-400 hover:text-black dark:hover:text-white rounded">
<X className="w-4 h-4" />
</button>
</div>
<div className="overflow-auto flex-1">
{auditLogs && (
<table className="w-full text-sm">
<thead className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 text-xs text-gray-500 dark:text-gray-400 sticky top-0">
<tr>
<th className="px-4 py-2 text-left"></th>
<th className="px-4 py-2 text-left"></th>
<th className="px-4 py-2 text-left">IP</th>
<th className="px-4 py-2 text-left">UA</th>
<th className="px-4 py-2 text-center"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
{auditLogs.length === 0 ? (
<tr><td colSpan={5} className="px-4 py-8 text-center text-gray-400"></td></tr>
) : currentAuditLogs?.map((log, i) => (
<tr key={i} className="hover:bg-gray-50 dark:hover:bg-gray-800">
<td className="px-4 py-2 text-xs text-gray-600 dark:text-gray-400 whitespace-nowrap">{log.time}</td>
<td className="px-4 py-2 text-xs text-gray-700 dark:text-gray-300">{log.action}</td>
<td className="px-4 py-2 text-xs font-mono text-gray-500 dark:text-gray-400">{log.ip || '-'}</td>
<td className="px-4 py-2 text-xs text-gray-500 dark:text-gray-400 max-w-[200px] truncate" title={log.user_agent}>{log.user_agent || '-'}</td>
<td className="px-4 py-2 text-center">
{log.success !== undefined ? (
log.success ? (
<span className="inline-flex px-2 py-0.5 rounded text-xs bg-green-50 text-green-700 dark:bg-green-900/30 dark:text-green-400"></span>
) : (
<span className="inline-flex px-2 py-0.5 rounded text-xs bg-red-50 text-red-600 dark:bg-red-900/30 dark:text-red-400" title={log.error}>{log.error ? '失败' : '失败'}</span>
)
) : (
<span className="text-gray-400">-</span>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
{loginLogs && (
<table className="w-full text-sm">
<thead className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 text-xs text-gray-500 dark:text-gray-400 sticky top-0">
<tr>
<th className="px-4 py-2 text-left"></th>
<th className="px-4 py-2 text-left"> IP</th>
<th className="px-4 py-2 text-left">UA</th>
<th className="px-4 py-2 text-center"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
{loginLogs.length === 0 ? (
<tr><td colSpan={4} className="px-4 py-8 text-center text-gray-400"></td></tr>
) : currentLoginLogs?.map((log, i) => (
<tr key={i} className="hover:bg-gray-50 dark:hover:bg-gray-800">
<td className="px-4 py-2 text-xs text-gray-600 dark:text-gray-400 whitespace-nowrap">{log.time}</td>
<td className="px-4 py-2 text-xs font-mono text-gray-500 dark:text-gray-400">{log.ip}</td>
<td className="px-4 py-2 text-xs text-gray-500 dark:text-gray-400 max-w-[250px] truncate" title={log.user_agent}>{log.user_agent}</td>
<td className="px-4 py-2 text-center">
{log.success ? (
<span className="inline-flex px-2 py-0.5 rounded text-xs bg-green-50 text-green-700 dark:bg-green-900/30 dark:text-green-400"></span>
) : (
<span className="inline-flex px-2 py-0.5 rounded text-xs bg-red-50 text-red-600 dark:bg-red-900/30 dark:text-red-400"></span>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
{currentLogTotal > 0 && (
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-gray-200 dark:border-gray-700 px-5 py-3">
<div className="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
<span>
{logStart + 1}-{Math.min(logStart + logPageSize, currentLogTotal)} / {currentLogTotal}
</span>
<select
value={logPageSize}
onChange={(event) => {
setLogPageSize(Number(event.target.value))
setLogPage(1)
}}
className="h-7 rounded border border-gray-300 bg-white px-2 text-xs text-gray-700 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300"
>
<option value={10}>10 / </option>
<option value={20}>20 / </option>
<option value={50}>50 / </option>
</select>
</div>
<div className="flex items-center gap-1">
<button onClick={() => setLogPage(1)} disabled={currentLogPage === 1} className="rounded border border-gray-200 px-2.5 py-1.5 text-xs text-gray-600 hover:bg-gray-50 disabled:opacity-40 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800"></button>
<button onClick={() => setLogPage((page) => Math.max(1, page - 1))} disabled={currentLogPage === 1} className="rounded border border-gray-200 px-2.5 py-1.5 text-xs text-gray-600 hover:bg-gray-50 disabled:opacity-40 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800"></button>
<span className="px-2 text-xs text-gray-500 dark:text-gray-400">{currentLogPage} / {logTotalPages}</span>
<button onClick={() => setLogPage((page) => Math.min(logTotalPages, page + 1))} disabled={currentLogPage === logTotalPages} className="rounded border border-gray-200 px-2.5 py-1.5 text-xs text-gray-600 hover:bg-gray-50 disabled:opacity-40 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800"></button>
<button onClick={() => setLogPage(logTotalPages)} disabled={currentLogPage === logTotalPages} className="rounded border border-gray-200 px-2.5 py-1.5 text-xs text-gray-600 hover:bg-gray-50 disabled:opacity-40 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800"></button>
</div>
</div>
)}
</div>
</div>
)}
</div>
)
}
+205 -44
View File
@@ -48,6 +48,7 @@ export interface Container {
id: number
uuid: string
name: string
virtualization?: string
template: string
vcpu: number
ram_mb: number
@@ -80,20 +81,26 @@ export interface Container {
snapshot_schedule_last_run: string
snapshot_schedule_next_run: string
snapshot_schedule_created_by: string
policy_blocked?: boolean
policy_blocked_reason?: string
policy_blocked_at?: string
}
export interface Template {
id: string
name: string
type?: string
distro: string
release: string
arch: string
variant?: string
desktop?: string
description: string
}
export interface CreateContainerRequest {
name: string
virtualization: string
template_id: string
vcpu: number
cpu_percent: number
@@ -129,6 +136,16 @@ export interface IPv6Status {
prefixes: IPv6PrefixInfo[]
}
export interface IPv4PrefixInfo {
interface: string
address: string
prefix: string
prefix_len: number
subnet_mask: string
gateway: string
source: string
}
export interface DashboardStats {
total_containers: number
running: number
@@ -154,8 +171,96 @@ export interface HostInfo {
load: { load1: number; load5: number; load15: number }
}
export interface HostProbeReport {
generated_at: string
hostname: string
kernel: string
os: string
cpu: {
model: string
cores: number
threads: number
architecture: string
flags: string[]
has_integrated_gpu: boolean
virtualization: boolean
virtualization_key: string
}
memory: {
total_mb: number
used_mb: number
free_mb: number
modules: Array<{
locator: string
size: string
type: string
speed: string
manufacturer: string
part_number: string
serial_number: string
}>
}
disks: Array<{
name: string
path: string
model: string
serial: string
size_bytes: number
type: string
rotational: boolean
mountpoints: string[]
health: string
health_detail: string
smart?: {
available: boolean
life_used_percent?: number
power_on_hours?: number
power_cycle_count?: number
read_data_bytes?: number
written_data_bytes?: number
read_commands?: number
write_commands?: number
wear_leveling_count?: string
erase_count?: string
media_errors?: number
}
}>
network_interfaces: Array<{
name: string
mac: string
state: string
speed_mbps: number
driver: string
model: string
ipv4: Array<{ interface: string; address: string; prefix_len: number; scope: string; gateway?: string }>
ipv6: Array<{ interface: string; address: string; prefix_len: number; scope: string; gateway?: string }>
}>
public_ipv4: string[]
ipv4_addresses: Array<{ interface: string; address: string; prefix_len: number; scope: string; gateway?: string }>
ipv4_prefixes: IPv4PrefixInfo[]
ipv6_addresses: Array<{ interface: string; address: string; prefix_len: number; scope: string; gateway?: string }>
ipv6_prefixes: IPv6PrefixInfo[]
gateways: Array<{ family: string; interface: string; gateway: string }>
gpus: Array<{ name: string; vendor: string; driver: string; type: string }>
runtime: {
lxc_available: boolean
kvm_available: boolean
dev_kvm: boolean
nested_virtualization: boolean
nested_detail: string
support_mode: string
}
system: {
uptime_seconds: number
uptime_text: string
process_count: number
}
environment: Array<{ key: string; label: string; ok: boolean; required: boolean; detail: string }>
}
export interface ContainerUsage {
memory_usage_bytes: number
memory_total_bytes?: number
cpu_usage_usec: number
cpu_usage_pct: number
disk_usage_bytes: number
@@ -167,6 +272,10 @@ export interface ContainerUsage {
disk_write_bytes: number
disk_read_bps: number
disk_write_bps: number
load1: number
load5: number
load15: number
guest_metrics?: boolean
}
export interface APIResponse<T = unknown> {
@@ -197,9 +306,58 @@ export interface LoginLog {
success: boolean
}
export interface AuditLog {
time: string
action: string
target: string
detail: string
user: string
}
export const getLoginLogs = () =>
api.get<APIResponse<LoginLog[]>>('/login-logs')
export interface SSLCertificateInfo {
subject: string
issuer: string
dns_names: string[]
ip_names: string[]
not_before: string
not_after: string
valid: boolean
}
export interface SSLSettings {
enabled: boolean
mode: 'disabled' | 'letsencrypt' | 'self_signed' | 'uploaded'
target: string
email?: string
cert_path?: string
key_path?: string
last_issued_at?: string
last_error?: string
detected_host?: string
certificate?: SSLCertificateInfo
mode_certificates?: Record<string, SSLSettings>
needs_restart?: boolean
}
export interface UpdateSSLSettingsRequest {
enabled: boolean
mode: 'disabled' | 'letsencrypt' | 'self_signed' | 'uploaded'
target?: string
email?: string
cert_pem?: string
key_pem?: string
apply_now?: boolean
}
export const getSSLSettings = () =>
api.get<APIResponse<SSLSettings>>('/ssl')
export const updateSSLSettings = (data: UpdateSSLSettingsRequest) =>
api.put<APIResponse<SSLSettings>>('/ssl', data)
// Containers
export const getContainers = () =>
api.get<APIResponse<Container[]>>('/containers')
@@ -225,8 +383,8 @@ export const restartContainer = (id: ContainerIdentifier) =>
export const reinstallContainer = (id: ContainerIdentifier, templateId: string) =>
api.post<APIResponse>(`/containers/${id}/reinstall`, { template_id: templateId })
export const resetSSHPassword = (id: ContainerIdentifier) =>
api.post<APIResponse<{ password: string }>>(`/containers/${id}/reset-password`)
export const resetSSHPassword = (id: ContainerIdentifier, password?: string) =>
api.post<APIResponse<{ password: string }>>(`/containers/${id}/reset-password`, password ? { password } : {})
export const getContainerUsage = (id: ContainerIdentifier) =>
api.get<APIResponse<ContainerUsage>>(`/containers/${id}/usage`)
@@ -330,6 +488,7 @@ export const getTemplates = () =>
export interface ImageInfo {
id: string
name: string
type: string
distro: string
release: string
arch: string
@@ -337,14 +496,24 @@ export interface ImageInfo {
downloaded: boolean
enabled: boolean
downloading: boolean
progress: number
downloaded_bytes: number
total_bytes: number
stage?: string
error?: string
size_bytes: number
manual_path?: string
desktop?: string
}
export const getImages = () =>
api.get<APIResponse<ImageInfo[]>>('/images')
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) =>
api.delete<APIResponse>('/images/delete', { data: { template_id: templateId } })
@@ -352,8 +521,8 @@ export const deleteImage = (templateId: string) =>
export const toggleImage = (templateId: string, enabled: boolean) =>
api.put<APIResponse>('/images/toggle', { template_id: templateId, enabled })
export const getEnabledImages = () =>
api.get<APIResponse<Template[]>>('/images/enabled')
export const getEnabledImages = (virtualization = 'lxc') =>
api.get<APIResponse<Template[]>>('/images/enabled', { params: { type: virtualization } })
// Dashboard
export const getDashboard = () =>
@@ -362,44 +531,8 @@ export const getDashboard = () =>
export const getHostInfo = () =>
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')
export const getHostReport = () =>
api.get<APIResponse<HostProbeReport>>('/host-report')
// Snapshots
export interface Snapshot {
@@ -464,6 +597,12 @@ export const getWebSSHUrl = (containerName: string) => {
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
export interface Task {
id: string
@@ -538,6 +677,19 @@ export interface SecuritySummary {
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 = () =>
api.get<APIResponse<SecurityAlert[]>>('/security/alerts')
@@ -545,14 +697,23 @@ export const checkContainerSecurity = (containerName: string) =>
api.post<APIResponse>('/security/check', { container_name: containerName })
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 = () =>
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) =>
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
export const getVersion = () =>
api.get<APIResponse<{ version: string }>>('/version')
+44
View File
@@ -0,0 +1,44 @@
export async function copyToClipboard(text: string): Promise<boolean> {
if (!text) return false
if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text)
return true
} catch {
// Fall through for non-secure HTTP origins where Clipboard API is blocked.
}
}
const textarea = document.createElement('textarea')
textarea.value = text
textarea.setAttribute('readonly', '')
textarea.style.position = 'fixed'
textarea.style.top = '0'
textarea.style.left = '0'
textarea.style.width = '1px'
textarea.style.height = '1px'
textarea.style.opacity = '0'
textarea.style.pointerEvents = 'none'
const selection = document.getSelection()
const selectedRange = selection?.rangeCount ? selection.getRangeAt(0) : null
document.body.appendChild(textarea)
textarea.focus({ preventScroll: true })
textarea.select()
textarea.setSelectionRange(0, textarea.value.length)
let copied = false
try {
copied = document.execCommand('copy')
} finally {
document.body.removeChild(textarea)
if (selection && selectedRange) {
selection.removeAllRanges()
selection.addRange(selectedRange)
}
}
return copied
}
+1
View File
@@ -14,5 +14,6 @@ export default defineConfig({
},
build: {
outDir: 'dist',
target: 'es2022',
}
})
+858 -94
View File
File diff suppressed because it is too large Load Diff