mirror of
https://github.com/MengMengCode/CLICD.git
synced 2026-08-05 05:36:07 +08:00
Compare commits
84 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e6551bf4ae | |||
| 5bf2b6534a | |||
| 917afc3157 | |||
| c8081edbac | |||
| 95eb00a31d | |||
| f4edf94800 | |||
| 82b42e7961 | |||
| e971d99070 | |||
| 8dd09ff009 | |||
| 7f4755788a | |||
| c24df1d42f | |||
| cf00d0d03d | |||
| 79be2d5cbd | |||
| e66327db29 | |||
| 2b4fe4f5bc | |||
| a923daa7a2 | |||
| c46f84c66e | |||
| 835bb51c6e | |||
| 7aed51e86b | |||
| e364807fb9 | |||
| c63ce02709 | |||
| 54f9ed7f7d | |||
| b01f9fe301 | |||
| d03e2c4c0c | |||
| 33603f5776 | |||
| 9ad7bcc97a | |||
| f3a1687a18 | |||
| 49b13af91c | |||
| e79609281f | |||
| 2fa130a2b6 | |||
| 14d2192b05 | |||
| 9f5ad94a83 | |||
| ac6587f2bc | |||
| 6fad37b844 | |||
| d0eb92eaab | |||
| 5207082cd1 | |||
| 608b50f18a | |||
| b58a6b1030 | |||
| 366f889a8c | |||
| 814441e9a0 | |||
| aed11af105 | |||
| 3d95bb33c1 | |||
| ade1c6c093 | |||
| 5c4cc1cab3 | |||
| 109e47170f | |||
| 34637cc79d | |||
| 7d48889eea | |||
| 2bcdb9e095 | |||
| 2ab42e7f57 | |||
| 3257cbb2a3 | |||
| 1ff5d7a85e | |||
| a99781d418 | |||
| b993e57d05 | |||
| 7ae0c91813 | |||
| aab58aca6e | |||
| 0b27604f95 | |||
| 460614e274 | |||
| 007811ab41 | |||
| 95af3e44f2 | |||
| 2ad17fa520 | |||
| 08a1a057e7 | |||
| e9f657ab17 | |||
| 7e5da67de4 | |||
| c99f3f6d55 | |||
| 2df92be501 | |||
| f8d16ca792 | |||
| be17f669f7 | |||
| 65fc787070 | |||
| 245c57449c | |||
| 6dd7079e23 | |||
| 422e48b524 | |||
| 3488b6db56 | |||
| c7ba19fa34 | |||
| ffedf801e7 | |||
| 49d5a65357 | |||
| 8f32765ffe | |||
| 21b87d3d56 | |||
| 28f81a8f1c | |||
| 6dcd5bd06c | |||
| 8b402fcfa1 | |||
| 8dd01fe714 | |||
| a9784539ea | |||
| bb8a646de7 | |||
| a2da61e076 |
@@ -0,0 +1,15 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
|
||||
patreon: # Replace with a single Patreon username
|
||||
open_collective: # Replace with a single Open Collective username
|
||||
ko_fi: # Replace with a single Ko-fi username
|
||||
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||
liberapay: # Replace with a single Liberapay username
|
||||
issuehunt: # Replace with a single IssueHunt username
|
||||
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
|
||||
polar: # Replace with a single Polar username
|
||||
buy_me_a_coffee: mengmengcode
|
||||
thanks_dev: # Replace with a single thanks.dev username
|
||||
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
||||
@@ -35,6 +35,15 @@ jobs:
|
||||
go-version: "1.22.x"
|
||||
cache-dependency-path: backend/go.sum
|
||||
|
||||
- name: Set version from tag
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
|
||||
echo "CLICD_VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV
|
||||
else
|
||||
echo "CLICD_VERSION=dev" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
- name: Build
|
||||
shell: bash
|
||||
run: bash build.sh
|
||||
|
||||
@@ -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
|
||||
@@ -58,8 +58,12 @@ backend/tmp/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.claude/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
linux.txt
|
||||
push-release.ps1
|
||||
deploy.ps1
|
||||
backend/clicd
|
||||
|
||||
@@ -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.
|
||||
@@ -10,80 +10,105 @@
|
||||
<img alt="TypeScript" src="https://img.shields.io/badge/TypeScript-5-3178C6?style=flat-square&logo=typescript&logoColor=white">
|
||||
<img alt="Vite" src="https://img.shields.io/badge/Vite-5-646CFF?style=flat-square&logo=vite&logoColor=white">
|
||||
<img alt="Tailwind CSS" src="https://img.shields.io/badge/Tailwind_CSS-3-06B6D4?style=flat-square&logo=tailwindcss&logoColor=white">
|
||||
<img alt="LXC" src="https://img.shields.io/badge/LXC-container-111111?style=flat-square">
|
||||
<img alt="LXC" src="https://img.shields.io/badge/LXC-Supported-111111?style=flat-square">
|
||||
<img alt="KVM" src="https://img.shields.io/badge/KVM-Supported-EE0000?style=flat-square">
|
||||
</p>
|
||||
|
||||
CLICD 是一个面向 LXC 的轻量容器管理面板,提供 Web 控制台、CLI、批量任务、镜像管理、NAT 端口、IPv6 分配、WebSSH、资源限制、流量限制和安全告警能力。它适合用来管理小型 VPS 上的 LXC 容器,也适合需要批量创建和分发子用户管理链接的场景。
|
||||
<p align="center">
|
||||
<img alt="WebSSH" src="https://img.shields.io/badge/WebSSH-Built--in-009688?style=flat-square">
|
||||
<img alt="VNC" src="https://img.shields.io/badge/VNC-Supported-7B1FA2?style=flat-square">
|
||||
<img alt="IPv6" src="https://img.shields.io/badge/IPv6-Native-1976D2?style=flat-square">
|
||||
<img alt="NAT" src="https://img.shields.io/badge/NAT-Port_Forwarding-FF9800?style=flat-square">
|
||||
<img alt="REST API" src="https://img.shields.io/badge/API-REST-4CAF50?style=flat-square">
|
||||
<img alt="Multi User" src="https://img.shields.io/badge/Multi_User-Supported-8E24AA?style=flat-square">
|
||||
<img alt="Traffic Control" src="https://img.shields.io/badge/Traffic-Control-795548?style=flat-square">
|
||||
<img alt="Security Alert" src="https://img.shields.io/badge/Security-Alert-orange?style=flat-square">
|
||||
<img alt="CLI" src="https://img.shields.io/badge/CLI-Mode-424242?style=flat-square">
|
||||
<img alt="TLS" src="https://img.shields.io/badge/TLS-Let's_Encrypt-003A70?style=flat-square&logo=letsencrypt&logoColor=white">
|
||||
</p>
|
||||
|
||||
## 功能介绍
|
||||
CLICD is a lightweight virtualization management panel for LXC and KVM, featuring a web console, CLI management, batch operations, image management, NAT networking, IPv6 allocation, WebSSH, VNC access, resource controls, bandwidth limiting, and security alerting.
|
||||
It is designed for managing LXC containers and KVM virtual machines on VPS servers, and is particularly suitable for environments that require bulk provisioning and delegated access management through sub-user management links.
|
||||
|
||||
CLICD 是一个面向 LXC/KVM 的轻量虚拟化管理面板,提供 Web 控制台、CLI、批量任务、镜像管理、NAT 端口、IPv6 分配、WebSSH、VNC、资源限制、流量限制和安全告警能力。它适合用来管理小型 VPS 上的 LXC 容器和 KVM 虚拟机,也适合需要批量创建和分发子用户管理链接的场景。
|
||||
|
||||
## Features / 功能介绍
|
||||
|
||||
### English
|
||||
1. Supports Ubuntu, Debian, Alpine, CentOS, Arch Linux, Fedora, Rocky Linux, and other operating system images. Images can be downloaded on demand through the image management interface. For hosts with limited resources, lightweight distributions such as Alpine are recommended.
|
||||
2. Supports WebSSH management, allowing users to access container terminals directly from the browser without manually copying SSH credentials.
|
||||
3. Supports NAT4 port quotas, port forwarding, and protocol restrictions, as well as public IPv6 allocation. IPv6 assignment requires the host machine to have a routable IPv6 prefix.
|
||||
4. Supports both inbound and outbound traffic limits. Containers are automatically powered off when configured limits are reached, preventing bandwidth overuse.
|
||||
5. Supports container expiration dates. Expired containers are automatically shut down, and delegated users lose access until an administrator extends the expiration period.
|
||||
6. Includes lightweight conntrack-based security monitoring. The system does not store full logs of normal connections, but generates audit alerts for suspicious activities such as port scanning, lateral scanning, brute-force attempts, SMTP abuse, UDP reflection attacks, cryptocurrency mining ports, and proxy/VPN/Tor usage.
|
||||
7. Supports delegated management links. Administrators can assign specific containers to sub-users, while ensuring that each user can only manage the containers explicitly authorized to them.
|
||||
8. Provides a REST API for automating the management of containers, tasks, images, networking, traffic controls, and security alerts.
|
||||
9. Supports operating entirely through the CLI. When the web console is not required, administrators can stop and disable the systemd service and launch CLI-only mode using `clicd cli --no-web`.
|
||||
|
||||
### 中文
|
||||
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` 进入命令行模式。
|
||||
|
||||
## 技术栈
|
||||
## Technology Stack / 技术栈
|
||||
|
||||
- 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
|
||||
|
||||
## 安装
|
||||
## Installation / 安装
|
||||
|
||||
推荐使用最新 Release 一键安装。在目标服务器上执行:
|
||||
One-click Install / 一键安装:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/MengMengCode/CLICD/main/install.sh | sudo sh
|
||||
```
|
||||
|
||||
也可以下载 GitHub Actions 构建出的 Release 产物 `clicd-linux-amd64.tar.gz` 后手动安装:
|
||||
|
||||
```bash
|
||||
tar -xzf clicd-linux-amd64.tar.gz
|
||||
cd clicd-linux-amd64
|
||||
sudo ./install.sh
|
||||
```
|
||||
|
||||
安装完成后访问:
|
||||
|
||||
```text
|
||||
http://YOUR_SERVER_IP:8999
|
||||
```
|
||||
|
||||
首次启动时会自动初始化管理员账号:
|
||||
|
||||
```text
|
||||
Username: admin
|
||||
Password: 随机 16 位密码
|
||||
```
|
||||
|
||||
安装脚本会尝试从 systemd 日志中输出初始账号密码。如果机器上已经存在 `/root/.clicd/config.json`,则不会重新生成密码。
|
||||
|
||||
查看初始密码日志:
|
||||
|
||||
```bash
|
||||
journalctl -u clicd --no-pager -n 80 | grep -E "Username:|Password:"
|
||||
```
|
||||
|
||||
卸载 CLICD:
|
||||
One-click Uninstall / 一键卸载:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/MengMengCode/CLICD/main/install.sh | sudo sh -s -- uninstall
|
||||
```
|
||||
|
||||
默认只删除 CLICD 服务和 `/usr/local/bin/clicd`,保留 `/root/.clicd` 配置数据和 `/var/lib/lxc` 容器。需要同时删除配置数据时:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/MengMengCode/CLICD/main/install.sh | sudo sh -s -- uninstall --purge-data
|
||||
```
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
|
||||
## 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">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=MengMengCode/CLICD&type=date&theme=dark&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=MengMengCode/CLICD&type=date&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=MengMengCode/CLICD&type=date&legend=top-left" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
+26
-5
@@ -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
@@ -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=
|
||||
|
||||
+382
-150
@@ -2,120 +2,296 @@ package api
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"clicd/internal/config"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"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 {
|
||||
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 {
|
||||
b := make([]byte, 32)
|
||||
for i := range key {
|
||||
b[i%32] ^= key[i]
|
||||
@@ -123,26 +299,99 @@ func hashKey(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)
|
||||
for _, k := range config.AppConfig.ApiKeys {
|
||||
if k.KeyHash == hashed {
|
||||
if k.IPWhitelist == "" {
|
||||
return true
|
||||
}
|
||||
return isIPAllowed(clientIP, k.IPWhitelist)
|
||||
func matchApiKey(rawKey string) (idx int, needsRehash bool) {
|
||||
legacyHashed := legacyHashKey(rawKey)
|
||||
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 {
|
||||
if apiKey := strings.TrimSpace(r.Header.Get("X-API-Key")); apiKey != "" {
|
||||
return apiKey
|
||||
}
|
||||
auth := r.Header.Get("Authorization")
|
||||
if strings.HasPrefix(auth, "Bearer clicd_sk_") {
|
||||
return strings.TrimPrefix(auth, "Bearer ")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func isValidApiKeyRequest(r *http.Request) bool {
|
||||
_, 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)
|
||||
@@ -150,114 +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, "")
|
||||
}
|
||||
|
||||
// ApiKeyMiddleware authenticates requests via X-API-Key header or ?api_key query param
|
||||
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) {
|
||||
// Check header
|
||||
apiKey := r.Header.Get("X-API-Key")
|
||||
if apiKey == "" {
|
||||
// Check query param
|
||||
apiKey = r.URL.Query().Get("api_key")
|
||||
}
|
||||
if apiKey == "" {
|
||||
// Check Bearer token (some clients use this)
|
||||
auth := r.Header.Get("Authorization")
|
||||
if strings.HasPrefix(auth, "Bearer clicd_sk_") {
|
||||
apiKey = strings.TrimPrefix(auth, "Bearer ")
|
||||
}
|
||||
}
|
||||
|
||||
// Get client IP
|
||||
clientIP := r.RemoteAddr
|
||||
if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" {
|
||||
clientIP = strings.Split(forwarded, ",")[0]
|
||||
}
|
||||
if apiKey == "" || !validateApiKey(apiKey, clientIP) {
|
||||
key, ok := validateApiKeyRequest(r)
|
||||
if !ok {
|
||||
jsonResponse(w, http.StatusUnauthorized, APIResponse{Success: false, Message: "Invalid API key or IP not in whitelist"})
|
||||
return
|
||||
}
|
||||
|
||||
// Generate a short-lived JWT so downstream admin middleware passes
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
||||
"username": config.AppConfig.AdminUser,
|
||||
"api_key": true,
|
||||
"exp": time.Now().Add(5 * time.Minute).Unix(),
|
||||
"iat": time.Now().Unix(),
|
||||
})
|
||||
tokenString, _ := token.SignedString([]byte(config.AppConfig.JWTSecret))
|
||||
|
||||
// Set cookie for subsequent requests
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "clicd_token",
|
||||
Value: tokenString,
|
||||
Path: "/",
|
||||
HttpOnly: false,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: 300,
|
||||
})
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -100,10 +256,7 @@ func HandleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
ip := r.RemoteAddr
|
||||
if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" {
|
||||
ip = forwarded
|
||||
}
|
||||
ip := clientIP(r)
|
||||
ua := r.Header.Get("User-Agent")
|
||||
|
||||
if req.Username != config.AppConfig.AdminUser {
|
||||
@@ -192,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) {
|
||||
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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -2,13 +2,16 @@ package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"clicd/internal/config"
|
||||
"clicd/internal/lxc"
|
||||
"clicd/internal/version"
|
||||
)
|
||||
|
||||
var lxcManager = lxc.NewManager()
|
||||
@@ -17,65 +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"})
|
||||
@@ -83,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})
|
||||
}
|
||||
@@ -101,10 +210,15 @@ 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 !isImageEnabledAndDownloaded(cfg.TemplateID, cfg.Virtualization) {
|
||||
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Template is not enabled or downloaded"})
|
||||
return
|
||||
}
|
||||
if cfg.VCPU <= 0 {
|
||||
cfg.VCPU = 1
|
||||
}
|
||||
@@ -114,14 +228,42 @@ func createContainer(w http.ResponseWriter, r *http.Request) {
|
||||
if cfg.DiskGB < 1 {
|
||||
cfg.DiskGB = 5
|
||||
}
|
||||
if cfg.PortMappingCount < 2 {
|
||||
if cfg.PortMappingCount < 0 {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Port mapping count cannot be negative"})
|
||||
return
|
||||
}
|
||||
if cfg.WantsNAT() && cfg.PortMappingCount < 2 {
|
||||
cfg.PortMappingCount = 2
|
||||
} else if !cfg.WantsNAT() {
|
||||
cfg.PortMappingCount = 0
|
||||
cfg.ExtraPorts = nil
|
||||
}
|
||||
if cfg.PortMappingCount > 64 {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Port mapping count cannot exceed 64"})
|
||||
return
|
||||
}
|
||||
if err := validateContainerResourceRequest(cfg.VCPU, cfg.RAMMB, cfg.DiskGB); err != nil {
|
||||
if cfg.IPv4Count < 0 || cfg.IPv6Count < 0 {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "IP address count cannot be negative"})
|
||||
return
|
||||
}
|
||||
if cfg.IPv4Count > 64 || cfg.IPv6Count > 64 {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "IP address count cannot exceed 64"})
|
||||
return
|
||||
}
|
||||
if !cfg.AssignIPv4 && len(cfg.PublicIPv4s) == 0 {
|
||||
cfg.IPv4Count = 0
|
||||
}
|
||||
if !cfg.AssignIPv6 && len(cfg.IPv6Addresses) == 0 {
|
||||
cfg.IPv6Count = 0
|
||||
}
|
||||
if !hasRequestedNetwork(cfg) {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: noNetworkSelectedMessage})
|
||||
return
|
||||
}
|
||||
if cfg.SnapshotLimit <= 0 {
|
||||
cfg.SnapshotLimit = config.DefaultSnapshotLimit
|
||||
}
|
||||
if err := validateRuntimeResourceRequest(cfg.Virtualization, cfg.VCPU, cfg.RAMMB, cfg.DiskGB); err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
@@ -137,7 +279,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
|
||||
}
|
||||
@@ -150,11 +292,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
|
||||
@@ -163,7 +309,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
|
||||
@@ -252,7 +398,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
|
||||
}
|
||||
@@ -265,13 +411,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) {
|
||||
@@ -280,24 +430,11 @@ func getRandomPort(w http.ResponseWriter, r *http.Request, id int) {
|
||||
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"})
|
||||
return
|
||||
}
|
||||
// Find a random unused port between 10000-65535
|
||||
used := map[int]bool{}
|
||||
for _, pm := range c.PortMappings {
|
||||
used[pm.HostPort] = true
|
||||
}
|
||||
// Also check all containers
|
||||
for _, oc := range config.AppConfig.Containers {
|
||||
if oc.ID == id {
|
||||
continue
|
||||
}
|
||||
for _, pm := range oc.PortMappings {
|
||||
used[pm.HostPort] = true
|
||||
}
|
||||
}
|
||||
hostIP := strings.TrimSpace(r.URL.Query().Get("host_ip"))
|
||||
// Try random ports
|
||||
for tries := 0; tries < 100; tries++ {
|
||||
port := 10000 + (int(time.Now().UnixNano()) % 55535)
|
||||
if !used[port] {
|
||||
if lxc.HostPortAvailable(c, hostIP, port, "tcp") {
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]int{"port": port}})
|
||||
return
|
||||
}
|
||||
@@ -311,6 +448,13 @@ 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
|
||||
}
|
||||
templates := lxc.GetTemplates()
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: templates})
|
||||
}
|
||||
@@ -321,10 +465,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 {
|
||||
@@ -348,6 +493,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})
|
||||
}
|
||||
@@ -358,7 +506,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
|
||||
@@ -370,6 +535,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 {
|
||||
@@ -438,3 +626,14 @@ func deletePortMapping(w http.ResponseWriter, r *http.Request, id int, indexStr
|
||||
}
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: mappings})
|
||||
}
|
||||
|
||||
// HandleVersion returns the current CLICD version.
|
||||
func HandleVersion(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||
return
|
||||
}
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]string{
|
||||
"version": version.Current(),
|
||||
}})
|
||||
}
|
||||
|
||||
+1345
-14
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,43 @@
|
||||
package api
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestExtractCertbotVersion(t *testing.T) {
|
||||
tests := []struct {
|
||||
output string
|
||||
want string
|
||||
}{
|
||||
{"certbot 5.4.0", "5.4.0"},
|
||||
{"certbot v5.10.1", "5.10.1"},
|
||||
{"certbot, version 4.9", "4.9"},
|
||||
{"installed", ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
if got := extractCertbotVersion(tt.output); got != tt.want {
|
||||
t.Fatalf("extractCertbotVersion(%q) = %q, want %q", tt.output, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCertbotVersionAtLeast54(t *testing.T) {
|
||||
tests := []struct {
|
||||
version string
|
||||
want bool
|
||||
}{
|
||||
{"5.4", true},
|
||||
{"5.4.0", true},
|
||||
{"5.10", true},
|
||||
{"6.0.0", true},
|
||||
{"5.3.9", false},
|
||||
{"4.99", false},
|
||||
{"5", false},
|
||||
{"", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
if got := certbotVersionAtLeast(tt.version, 5, 4); got != tt.want {
|
||||
t.Fatalf("certbotVersionAtLeast(%q, 5, 4) = %v, want %v", tt.version, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
+381
-66
@@ -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,20 +525,60 @@ 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: result})
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
enabledSet := getEnabledImageSet()
|
||||
return enabledSet[tmpl.ID] && isImageDownloaded(tmpl.Distro, tmpl.Release, tmpl.Arch)
|
||||
}
|
||||
|
||||
func ensureImageEnabled(id string) {
|
||||
// If the enabled list is empty, all templates are currently enabled by default.
|
||||
// We must populate the list with all template IDs first so that explicit toggles stick.
|
||||
@@ -279,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
|
||||
}
|
||||
@@ -304,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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,224 +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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func clientIP(r *http.Request) string {
|
||||
host, _, err := net.SplitHostPort(strings.TrimSpace(r.RemoteAddr))
|
||||
if err == nil {
|
||||
return host
|
||||
}
|
||||
return strings.TrimSpace(r.RemoteAddr)
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"sort"
|
||||
"strconv"
|
||||
|
||||
"clicd/internal/config"
|
||||
"clicd/internal/lxc"
|
||||
)
|
||||
|
||||
type routeCapacity struct {
|
||||
Used int `json:"used"`
|
||||
Remaining string `json:"remaining"`
|
||||
Total string `json:"total"`
|
||||
}
|
||||
|
||||
type nat4Route struct {
|
||||
ContainerID int `json:"container_id"`
|
||||
ContainerName string `json:"container_name"`
|
||||
LXCName string `json:"lxc_name"`
|
||||
Status string `json:"status"`
|
||||
IP string `json:"ip"`
|
||||
HostIP string `json:"host_ip"`
|
||||
HostPort int `json:"host_port"`
|
||||
ContainerPort int `json:"container_port"`
|
||||
Protocol string `json:"protocol"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
type ipv4Route struct {
|
||||
ContainerID int `json:"container_id"`
|
||||
ContainerName string `json:"container_name"`
|
||||
LXCName string `json:"lxc_name"`
|
||||
Status string `json:"status"`
|
||||
Address string `json:"address"`
|
||||
Interface string `json:"interface"`
|
||||
PrefixLen int `json:"prefix_len,omitempty"`
|
||||
Gateway string `json:"gateway,omitempty"`
|
||||
}
|
||||
|
||||
type ipv6Route struct {
|
||||
ContainerID int `json:"container_id"`
|
||||
ContainerName string `json:"container_name"`
|
||||
LXCName string `json:"lxc_name"`
|
||||
Status string `json:"status"`
|
||||
Address string `json:"address"`
|
||||
PrefixLen int `json:"prefix_len"`
|
||||
Interface string `json:"interface"`
|
||||
}
|
||||
|
||||
type routingResponse struct {
|
||||
NAT4 routeCapacity `json:"nat4"`
|
||||
IPv4 routeCapacity `json:"ipv4"`
|
||||
IPv6 routeCapacity `json:"ipv6"`
|
||||
HostPublicIPv4 lxc.PublicIPInfo `json:"host_public_ipv4"`
|
||||
PublicIPv4Addresses []lxc.PublicIPInfo `json:"public_ipv4_addresses"`
|
||||
IPv4Assignments []ipv4Route `json:"ipv4_assignments"`
|
||||
NAT4Mappings []nat4Route `json:"nat4_mappings"`
|
||||
IPv6Assignments []ipv6Route `json:"ipv6_assignments"`
|
||||
IPv6Prefixes []lxc.IPv6PrefixInfo `json:"ipv6_prefixes"`
|
||||
}
|
||||
|
||||
type routingPoolsRequest struct {
|
||||
Addresses *[]string `json:"addresses"`
|
||||
Items *[]config.PublicIPv4Assignment `json:"items"`
|
||||
IPv6Prefixes *[]config.PublicIPv6Prefix `json:"ipv6_prefixes"`
|
||||
}
|
||||
|
||||
type publicIPv4ScanRequest struct {
|
||||
CIDR string `json:"cidr"`
|
||||
Interface string `json:"interface"`
|
||||
Gateway string `json:"gateway"`
|
||||
Verify bool `json:"verify"`
|
||||
Limit int `json:"limit"`
|
||||
}
|
||||
|
||||
func HandleRouting(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
handleRoutingGet(w, r)
|
||||
case http.MethodPut:
|
||||
handleRoutingPoolsUpdate(w, r)
|
||||
default:
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||
}
|
||||
}
|
||||
|
||||
func HandleRoutingIPv4Scan(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||
return
|
||||
}
|
||||
if !requireScope(w, r, "routing:write") {
|
||||
return
|
||||
}
|
||||
var req publicIPv4ScanRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
|
||||
return
|
||||
}
|
||||
results, err := lxc.ScanPublicIPv4Segment(req.CIDR, req.Interface, req.Gateway, req.Verify, req.Limit)
|
||||
if err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: results})
|
||||
}
|
||||
|
||||
func handleRoutingGet(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireScope(w, r, "routing:read") {
|
||||
return
|
||||
}
|
||||
|
||||
nat4Mappings := make([]nat4Route, 0)
|
||||
usedPorts := map[int]bool{}
|
||||
ipv4Assignments := make([]ipv4Route, 0)
|
||||
ipv6Assignments := make([]ipv6Route, 0)
|
||||
|
||||
const nat4StartPort = 20000
|
||||
const nat4EndPort = 65535
|
||||
|
||||
for i := range config.AppConfig.Containers {
|
||||
c := &config.AppConfig.Containers[i]
|
||||
for _, pm := range c.PortMappings {
|
||||
if pm.HostPort >= nat4StartPort && pm.HostPort <= nat4EndPort {
|
||||
usedPorts[pm.HostPort] = true
|
||||
}
|
||||
nat4Mappings = append(nat4Mappings, nat4Route{
|
||||
ContainerID: c.ID,
|
||||
ContainerName: c.Name,
|
||||
LXCName: c.LxcName(),
|
||||
Status: c.Status,
|
||||
IP: c.IP,
|
||||
HostIP: pm.HostIP,
|
||||
HostPort: pm.HostPort,
|
||||
ContainerPort: pm.ContainerPort,
|
||||
Protocol: pm.Protocol,
|
||||
Description: pm.Description,
|
||||
})
|
||||
}
|
||||
for _, ip := range c.PublicIPv4s {
|
||||
if ip.Address == "" {
|
||||
continue
|
||||
}
|
||||
ipv4Assignments = append(ipv4Assignments, ipv4Route{
|
||||
ContainerID: c.ID,
|
||||
ContainerName: c.Name,
|
||||
LXCName: c.LxcName(),
|
||||
Status: c.Status,
|
||||
Address: ip.Address,
|
||||
Interface: ip.Interface,
|
||||
PrefixLen: ip.PrefixLen,
|
||||
Gateway: ip.Gateway,
|
||||
})
|
||||
}
|
||||
c.NormalizeNetworkAssignments()
|
||||
for _, ip := range c.IPv6Addresses {
|
||||
if ip.Address == "" {
|
||||
continue
|
||||
}
|
||||
ipv6Assignments = append(ipv6Assignments, ipv6Route{
|
||||
ContainerID: c.ID,
|
||||
ContainerName: c.Name,
|
||||
LXCName: c.LxcName(),
|
||||
Status: c.Status,
|
||||
Address: ip.Address,
|
||||
PrefixLen: ip.PrefixLen,
|
||||
Interface: ip.Interface,
|
||||
})
|
||||
}
|
||||
}
|
||||
sort.SliceStable(nat4Mappings, func(i, j int) bool {
|
||||
if nat4Mappings[i].HostPort == nat4Mappings[j].HostPort {
|
||||
if nat4Mappings[i].HostIP != nat4Mappings[j].HostIP {
|
||||
return nat4Mappings[i].HostIP < nat4Mappings[j].HostIP
|
||||
}
|
||||
return nat4Mappings[i].ContainerName < nat4Mappings[j].ContainerName
|
||||
}
|
||||
return nat4Mappings[i].HostPort < nat4Mappings[j].HostPort
|
||||
})
|
||||
sort.SliceStable(ipv4Assignments, func(i, j int) bool {
|
||||
return ipv4Assignments[i].Address < ipv4Assignments[j].Address
|
||||
})
|
||||
sort.SliceStable(ipv6Assignments, func(i, j int) bool {
|
||||
return ipv6Assignments[i].Address < ipv6Assignments[j].Address
|
||||
})
|
||||
|
||||
const totalNAT4Ports = nat4EndPort - nat4StartPort + 1
|
||||
nat4Used := len(usedPorts)
|
||||
nat4Remaining := totalNAT4Ports - nat4Used
|
||||
if nat4Remaining < 0 {
|
||||
nat4Remaining = 0
|
||||
}
|
||||
|
||||
prefixes := lxc.DetectPublicIPv6Prefixes()
|
||||
hostPublicIPv4 := lxc.DetectPublicIPv4()
|
||||
publicIPv4s := lxc.DetectPublicIPv4Candidates()
|
||||
ipv4Total := len(publicIPv4s)
|
||||
ipv4Used := len(ipv4Assignments)
|
||||
ipv4Remaining := ipv4Total - ipv4Used
|
||||
if ipv4Remaining < 0 {
|
||||
ipv4Remaining = 0
|
||||
}
|
||||
ipv6Total := totalIPv6Capacity(prefixes)
|
||||
ipv6Remaining := subtractCapacity(ipv6Total, len(ipv6Assignments))
|
||||
|
||||
jsonResponse(w, http.StatusOK, APIResponse{
|
||||
Success: true,
|
||||
Data: routingResponse{
|
||||
NAT4: routeCapacity{
|
||||
Used: nat4Used,
|
||||
Remaining: strconv.Itoa(nat4Remaining),
|
||||
Total: strconv.Itoa(totalNAT4Ports),
|
||||
},
|
||||
IPv4: routeCapacity{
|
||||
Used: ipv4Used,
|
||||
Remaining: strconv.Itoa(ipv4Remaining),
|
||||
Total: strconv.Itoa(ipv4Total),
|
||||
},
|
||||
IPv6: routeCapacity{
|
||||
Used: len(ipv6Assignments),
|
||||
Remaining: ipv6Remaining,
|
||||
Total: ipv6Total,
|
||||
},
|
||||
HostPublicIPv4: hostPublicIPv4,
|
||||
PublicIPv4Addresses: publicIPv4s,
|
||||
IPv4Assignments: ipv4Assignments,
|
||||
NAT4Mappings: nat4Mappings,
|
||||
IPv6Assignments: ipv6Assignments,
|
||||
IPv6Prefixes: prefixes,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func handleRoutingPoolsUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireScope(w, r, "routing:write") {
|
||||
return
|
||||
}
|
||||
var req routingPoolsRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
if req.Items != nil || req.Addresses != nil {
|
||||
items := []config.PublicIPv4Assignment{}
|
||||
if req.Items != nil {
|
||||
items = *req.Items
|
||||
} else if req.Addresses != nil {
|
||||
items = make([]config.PublicIPv4Assignment, 0, len(*req.Addresses))
|
||||
for _, address := range *req.Addresses {
|
||||
items = append(items, config.PublicIPv4Assignment{Address: address})
|
||||
}
|
||||
}
|
||||
normalized, err := lxc.NormalizePublicIPv4Pool(items)
|
||||
if err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
allowed := map[string]bool{}
|
||||
for _, item := range normalized {
|
||||
allowed[item.Address] = true
|
||||
}
|
||||
for _, c := range config.AppConfig.Containers {
|
||||
for _, item := range c.PublicIPv4s {
|
||||
if item.Address != "" && !allowed[item.Address] {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{
|
||||
Success: false,
|
||||
Message: "IPv4 " + item.Address + " is assigned to container " + c.Name + " and cannot be removed from the pool",
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
config.AppConfig.PublicIPv4Pool = normalized
|
||||
}
|
||||
|
||||
if req.IPv6Prefixes != nil {
|
||||
normalized, err := lxc.NormalizePublicIPv6Prefixes(*req.IPv6Prefixes)
|
||||
if err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
parsedPrefixes := make([]netip.Prefix, 0, len(normalized))
|
||||
for _, item := range normalized {
|
||||
prefix, err := netip.ParsePrefix(item.Prefix)
|
||||
if err == nil {
|
||||
parsedPrefixes = append(parsedPrefixes, prefix)
|
||||
}
|
||||
}
|
||||
for _, c := range config.AppConfig.Containers {
|
||||
c.NormalizeNetworkAssignments()
|
||||
for _, item := range c.IPv6Addresses {
|
||||
if item.Address == "" {
|
||||
continue
|
||||
}
|
||||
addr, err := netip.ParseAddr(item.Address)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
contained := false
|
||||
for _, prefix := range parsedPrefixes {
|
||||
if prefix.Contains(addr) {
|
||||
contained = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !contained {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{
|
||||
Success: false,
|
||||
Message: "IPv6 " + item.Address + " is assigned to container " + c.Name + " and cannot be removed from the pool",
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
config.AppConfig.PublicIPv6Prefixes = normalized
|
||||
}
|
||||
|
||||
if err := config.SaveConfig(); err != nil {
|
||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to save configuration"})
|
||||
return
|
||||
}
|
||||
handleRoutingGet(w, r)
|
||||
}
|
||||
|
||||
func totalIPv6Capacity(prefixes []lxc.IPv6PrefixInfo) string {
|
||||
if len(prefixes) == 0 {
|
||||
return "0"
|
||||
}
|
||||
var total uint64
|
||||
for _, prefix := range prefixes {
|
||||
capacity := lxc.IPv6PrefixCapacity(prefix.PrefixLen)
|
||||
if capacity == "large" {
|
||||
return "large"
|
||||
}
|
||||
parsed, err := strconv.ParseUint(capacity, 10, 64)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if ^uint64(0)-total < parsed {
|
||||
return "large"
|
||||
}
|
||||
total += parsed
|
||||
}
|
||||
if total == 0 {
|
||||
return "0"
|
||||
}
|
||||
return strconv.FormatUint(total, 10)
|
||||
}
|
||||
|
||||
func subtractCapacity(total string, used int) string {
|
||||
if total == "" || total == "0" {
|
||||
return "0"
|
||||
}
|
||||
if total == "large" {
|
||||
return "large"
|
||||
}
|
||||
parsed, err := strconv.ParseInt(total, 10, 64)
|
||||
if err != nil {
|
||||
return total
|
||||
}
|
||||
remaining := parsed - int64(used)
|
||||
if remaining < 0 {
|
||||
remaining = 0
|
||||
}
|
||||
return strconv.FormatInt(remaining, 10)
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"clicd/internal/config"
|
||||
"clicd/internal/kvm"
|
||||
"clicd/internal/lxc"
|
||||
)
|
||||
|
||||
var kvmManager = kvm.NewManager()
|
||||
|
||||
const noNetworkSelectedMessage = "请勾选任意一个可用网络"
|
||||
|
||||
func runtimeFromRequest(value string) string {
|
||||
return config.NormalizeVirtualization(value)
|
||||
}
|
||||
|
||||
func hasRequestedNetwork(cfg lxc.ContainerConfig) bool {
|
||||
return cfg.WantsNAT() || cfg.AssignIPv4 || len(cfg.PublicIPv4s) > 0 || cfg.AssignIPv6 || len(cfg.IPv6Addresses) > 0
|
||||
}
|
||||
|
||||
func runtimeFromTemplateID(templateID string) string {
|
||||
if kvm.FindImage(templateID) != nil {
|
||||
return config.VirtualizationKVM
|
||||
}
|
||||
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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -20,6 +20,34 @@ type LoginLog struct {
|
||||
|
||||
var loginLogs = make([]LoginLog, 0)
|
||||
|
||||
// HandleLanguage returns or updates the global panel language.
|
||||
func HandleLanguage(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]string{
|
||||
"language": config.NormalizeLanguage(config.AppConfig.Language),
|
||||
}})
|
||||
case http.MethodPost, http.MethodPut:
|
||||
var req struct {
|
||||
Language string `json:"language"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
|
||||
return
|
||||
}
|
||||
config.AppConfig.Language = config.NormalizeLanguage(req.Language)
|
||||
if err := config.SaveConfig(); err != nil {
|
||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to save language"})
|
||||
return
|
||||
}
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]string{
|
||||
"language": config.AppConfig.Language,
|
||||
}})
|
||||
default:
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||
}
|
||||
}
|
||||
|
||||
// RecordLoginLog adds a login attempt to the log (persisted to config)
|
||||
func RecordLoginLog(username, ip, userAgent string, success bool) {
|
||||
config.AddLoginLog(username, ip, userAgent, success)
|
||||
@@ -56,6 +84,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))
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"clicd/internal/config"
|
||||
)
|
||||
|
||||
func HandleSnapshots(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, "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})
|
||||
}
|
||||
|
||||
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:
|
||||
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Snapshot action not found"})
|
||||
}
|
||||
}
|
||||
|
||||
func listContainerSnapshots(w http.ResponseWriter, r *http.Request, containerID int) {
|
||||
c := config.FindContainer(containerID)
|
||||
if c == nil {
|
||||
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"})
|
||||
return
|
||||
}
|
||||
snapshots := config.ContainerSnapshots(containerID)
|
||||
sortSnapshotsNewestFirst(snapshots)
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]interface{}{
|
||||
"snapshots": snapshots,
|
||||
"quota": config.ContainerSnapshotLimit(c),
|
||||
"schedule": map[string]interface{}{
|
||||
"enabled": c.SnapshotScheduleEnabled,
|
||||
"interval_hours": c.SnapshotScheduleIntervalHours,
|
||||
"time": c.SnapshotScheduleTime,
|
||||
"last_run": c.SnapshotScheduleLastRun,
|
||||
"next_run": c.SnapshotScheduleNextRun,
|
||||
"created_by": c.SnapshotScheduleCreatedBy,
|
||||
},
|
||||
}})
|
||||
}
|
||||
|
||||
func createContainerSnapshot(w http.ResponseWriter, r *http.Request, containerID int) {
|
||||
user := requestUser(r)
|
||||
if isSubUserRequest(r) {
|
||||
c := config.FindContainer(containerID)
|
||||
limit := config.ContainerSnapshotLimit(c)
|
||||
if len(config.ContainerSnapshots(containerID)) >= limit {
|
||||
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Snapshot quota reached. Delete an old snapshot first."})
|
||||
return
|
||||
}
|
||||
}
|
||||
snapshot, err := createSnapshotByRuntime(containerID, user, false, 0)
|
||||
if err != nil {
|
||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
config.AddAuditLog("snapshot.create", snapshot.ContainerName, snapshot.ID, user)
|
||||
jsonResponse(w, http.StatusCreated, APIResponse{Success: true, Data: snapshot})
|
||||
}
|
||||
|
||||
func updateSnapshotQuota(w http.ResponseWriter, r *http.Request, containerID int) {
|
||||
if isSubUserRequest(r) {
|
||||
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Sub-users cannot change snapshot quota"})
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
SnapshotLimit int `json:"snapshot_limit"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
|
||||
return
|
||||
}
|
||||
if req.SnapshotLimit <= 0 {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Snapshot quota must be at least 1"})
|
||||
return
|
||||
}
|
||||
c := config.FindContainer(containerID)
|
||||
if c == nil {
|
||||
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"})
|
||||
return
|
||||
}
|
||||
c.SnapshotLimit = req.SnapshotLimit
|
||||
if err := config.SaveConfig(); err != nil {
|
||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to save config"})
|
||||
return
|
||||
}
|
||||
user := requestUser(r)
|
||||
config.AddAuditLog("snapshot.quota", c.Name, "limit="+strconv.Itoa(req.SnapshotLimit), user)
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]interface{}{
|
||||
"container": c,
|
||||
"quota": c.SnapshotLimit,
|
||||
}})
|
||||
}
|
||||
|
||||
func updateSnapshotSchedule(w http.ResponseWriter, r *http.Request, containerID int) {
|
||||
var req struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
IntervalHours int `json:"interval_hours"`
|
||||
Time string `json:"time"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
|
||||
return
|
||||
}
|
||||
if req.IntervalHours <= 0 {
|
||||
req.IntervalHours = 24
|
||||
}
|
||||
if req.IntervalHours < 24 {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Snapshot schedule interval cannot be less than 24 hours"})
|
||||
return
|
||||
}
|
||||
if req.Time == "" {
|
||||
req.Time = "03:00"
|
||||
}
|
||||
user := requestUser(r)
|
||||
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
|
||||
}
|
||||
|
||||
if req.Enabled {
|
||||
config.AddAuditLog("snapshot.schedule", c.Name, "enabled", user)
|
||||
} else {
|
||||
config.AddAuditLog("snapshot.schedule", c.Name, "disabled", user)
|
||||
}
|
||||
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]interface{}{
|
||||
"container": c,
|
||||
}})
|
||||
}
|
||||
|
||||
func deleteContainerSnapshot(w http.ResponseWriter, r *http.Request, containerID int, snapshotID string) {
|
||||
snapshot := config.FindSnapshot(snapshotID)
|
||||
if snapshot == nil || snapshot.ContainerID != containerID {
|
||||
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Snapshot not found"})
|
||||
return
|
||||
}
|
||||
user := requestUser(r)
|
||||
if err := deleteSnapshotByRuntime(snapshotID); err != nil {
|
||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
config.AddAuditLog("snapshot.delete", snapshot.ContainerName, snapshot.ID, user)
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Snapshot deleted"})
|
||||
}
|
||||
|
||||
func restoreContainerSnapshot(w http.ResponseWriter, r *http.Request, containerID int, snapshotID string) {
|
||||
snapshot := config.FindSnapshot(snapshotID)
|
||||
if snapshot == nil || snapshot.ContainerID != containerID {
|
||||
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Snapshot not found"})
|
||||
return
|
||||
}
|
||||
user := requestUser(r)
|
||||
if err := restoreSnapshotByRuntime(snapshotID); err != nil {
|
||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
config.AddAuditLog("snapshot.restore", snapshot.ContainerName, snapshot.ID, user)
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Snapshot restored"})
|
||||
}
|
||||
|
||||
func requestUser(r *http.Request) string {
|
||||
return requestActor(r)
|
||||
}
|
||||
|
||||
func sortSnapshotsNewestFirst(snapshots []config.Snapshot) {
|
||||
sort.SliceStable(snapshots, func(i, j int) bool {
|
||||
ti, _ := time.Parse("2006-01-02 15:04:05", snapshots[i].CreatedAt)
|
||||
tj, _ := time.Parse("2006-01-02 15:04:05", snapshots[j].CreatedAt)
|
||||
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
|
||||
}
|
||||
+125
-29
@@ -2,6 +2,7 @@ package api
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
@@ -26,6 +27,7 @@ type terminalResizeMessage struct {
|
||||
|
||||
type webSSHTicket struct {
|
||||
ContainerName string
|
||||
SubUser bool
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
@@ -40,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"`
|
||||
}
|
||||
@@ -51,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()
|
||||
@@ -73,7 +84,7 @@ func HandleWebSSHTicket(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// HandleWebSSH proxies an SSH session to the browser over WebSocket.
|
||||
func HandleWebSSH(w http.ResponseWriter, r *http.Request) {
|
||||
ticket := r.URL.Query().Get("ticket")
|
||||
ticket := webSSHTicketFromRequest(r)
|
||||
if ticket == "" {
|
||||
http.Error(w, "ticket required", http.StatusUnauthorized)
|
||||
return
|
||||
@@ -85,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
|
||||
}
|
||||
@@ -95,26 +107,48 @@ 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
|
||||
}
|
||||
ws, err := upgrader.Upgrade(w, r, nil)
|
||||
responseHeader := http.Header{}
|
||||
if protocol := webSSHTicketProtocol(r); protocol != "" {
|
||||
responseHeader.Set("Sec-WebSocket-Protocol", protocol)
|
||||
}
|
||||
ws, err := upgrader.Upgrade(w, r, responseHeader)
|
||||
if err != nil {
|
||||
log.Printf("WebSSH upgrade failed: %v", err)
|
||||
return
|
||||
@@ -122,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))
|
||||
@@ -141,7 +179,7 @@ func HandleWebSSH(w http.ResponseWriter, r *http.Request) {
|
||||
Auth: []ssh.AuthMethod{
|
||||
ssh.Password(c.SSHPassword),
|
||||
},
|
||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
||||
HostKeyCallback: containerHostKeyCallback(c),
|
||||
Timeout: 4 * time.Second,
|
||||
}
|
||||
|
||||
@@ -149,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()
|
||||
@@ -250,6 +311,41 @@ func HandleWebSSH(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("WebSSH disconnected for container %s", containerName)
|
||||
}
|
||||
|
||||
func containerHostKeyCallback(c *config.Container) ssh.HostKeyCallback {
|
||||
return func(hostname string, remote net.Addr, key ssh.PublicKey) error {
|
||||
sum := sha256.Sum256(key.Marshal())
|
||||
fingerprint := hex.EncodeToString(sum[:])
|
||||
if c.SSHHostKey != "" && c.SSHHostKey != fingerprint {
|
||||
return fmt.Errorf("container SSH host key mismatch")
|
||||
}
|
||||
if c.SSHHostKey == "" {
|
||||
c.SSHHostKey = fingerprint
|
||||
config.SaveConfig()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func webSSHTicketFromRequest(r *http.Request) string {
|
||||
for _, protocol := range websocket.Subprotocols(r) {
|
||||
const prefix = "clicd-ticket."
|
||||
if len(protocol) > len(prefix) && protocol[:len(prefix)] == prefix {
|
||||
return protocol[len(prefix):]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func webSSHTicketProtocol(r *http.Request) string {
|
||||
for _, protocol := range websocket.Subprotocols(r) {
|
||||
const prefix = "clicd-ticket."
|
||||
if len(protocol) > len(prefix) && protocol[:len(prefix)] == prefix {
|
||||
return protocol
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func streamSSHOutput(ws *websocket.Conn, writeMu *sync.Mutex, src io.Reader, done chan<- struct{}) {
|
||||
defer func() { done <- struct{}{} }()
|
||||
|
||||
@@ -278,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) {
|
||||
|
||||
@@ -0,0 +1,587 @@
|
||||
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"
|
||||
"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)
|
||||
}
|
||||
normalizedTarget, err := config.NormalizeSSLCertificateTarget(target)
|
||||
if err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
target = normalizedTarget
|
||||
|
||||
next, err := resolveSSLModeCertificate(mode, target, strings.TrimSpace(req.Email), req.CertPEM, req.KeyPEM)
|
||||
if err != nil {
|
||||
_ = 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)
|
||||
}
|
||||
certPath, keyPath, err := config.UploadedSSLPaths()
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
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)
|
||||
normalizedTarget, err := config.NormalizeSSLCertificateTarget(target)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
target = normalizedTarget
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
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
|
||||
}
|
||||
certPath, keyPath, err := config.SelfSignedSSLPaths()
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
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)
|
||||
normalizedTarget, err := config.NormalizeSSLCertificateTarget(target)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
target = normalizedTarget
|
||||
args := []string{"certonly", "--non-interactive", "--agree-tos", "--standalone"}
|
||||
if email != "" {
|
||||
args = append(args, "--email", email)
|
||||
} 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, keyPath, err := config.LetsEncryptSSLPaths(target)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if _, err := config.ReadableFileStat(certPath); err != nil {
|
||||
return "", "", fmt.Errorf("Let's Encrypt certificate file not found after issuance: %s", certPath)
|
||||
}
|
||||
if _, err := config.ReadableFileStat(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 {
|
||||
safeCertPath, err := config.ResolveSSLPath(certPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
safeKeyPath, err := config.ResolveSSLPath(keyPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
certPEM, err := os.ReadFile(safeCertPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
keyPEM, err := os.ReadFile(safeKeyPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
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")
|
||||
}
|
||||
safeCertPath, err := config.ResolveSSLPath(certPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, err := os.ReadFile(safeCertPath)
|
||||
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 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()
|
||||
}
|
||||
}
|
||||
+369
-55
@@ -21,12 +21,37 @@ func generateRandomStr(length int) string {
|
||||
return hex.EncodeToString(b)[:length]
|
||||
}
|
||||
|
||||
type subUserResponse struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password,omitempty"`
|
||||
ContainerNames []string `json:"container_names"`
|
||||
ContainerUUIDs []string `json:"container_uuids,omitempty"`
|
||||
AccessCode string `json:"access_code"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
func newSubUserResponse(su config.SubUser, password string) subUserResponse {
|
||||
return subUserResponse{
|
||||
ID: su.ID,
|
||||
Username: su.Username,
|
||||
Password: password,
|
||||
ContainerNames: su.ContainerNames,
|
||||
ContainerUUIDs: su.ContainerUUIDs,
|
||||
AccessCode: su.AccessCode,
|
||||
CreatedAt: su.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// HandleSubUserCreate creates a sub-user for a specific container
|
||||
func HandleSubUserCreate(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, "subuser:create") {
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
ContainerName string `json:"container_name"`
|
||||
@@ -44,32 +69,36 @@ 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 _, cn := range su.ContainerNames {
|
||||
if cn == containerName {
|
||||
for _, uuid := range su.ContainerUUIDs {
|
||||
if uuid == c.UUID {
|
||||
if su.AccessCode == "" {
|
||||
su.AccessCode = generateRandomStr(8)
|
||||
}
|
||||
if su.PassHash == "" && su.Password != "" {
|
||||
if hash, err := bcrypt.GenerateFromPassword([]byte(su.Password), bcrypt.DefaultCost); err == nil {
|
||||
su.PassHash = string(hash)
|
||||
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"
|
||||
}
|
||||
if su.Password == "" {
|
||||
su.Password = generateRandomStr(16)
|
||||
if hash, err := bcrypt.GenerateFromPassword([]byte(su.Password), bcrypt.DefaultCost); err == nil {
|
||||
su.PassHash = string(hash)
|
||||
}
|
||||
}
|
||||
su.Token = newSubUserToken(su.Username, []string{c.UUID}, time.Now().AddDate(1, 0, 0))
|
||||
su.ContainerNames = appendUniqueString(su.ContainerNames, containerName)
|
||||
su.ContainerUUIDs = appendUniqueString(su.ContainerUUIDs, c.UUID)
|
||||
config.SaveConfig()
|
||||
// Return existing
|
||||
jsonResponse(w, http.StatusOK, APIResponse{
|
||||
Success: true,
|
||||
Message: "Sub-user already exists",
|
||||
Data: *su,
|
||||
Message: message,
|
||||
Data: newSubUserResponse(*su, password),
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -84,16 +113,13 @@ func HandleSubUserCreate(w http.ResponseWriter, r *http.Request) {
|
||||
// Generate short access code (8 chars, for URL sharing)
|
||||
accessCode := generateRandomStr(8)
|
||||
|
||||
// Generate JWT for sub-user
|
||||
tokenStr := newSubUserToken(username, []string{c.UUID}, time.Now().AddDate(1, 0, 0))
|
||||
|
||||
subUser := config.SubUser{
|
||||
ID: "sub-" + generateRandomStr(8),
|
||||
Username: username,
|
||||
Password: password,
|
||||
PassHash: string(hash),
|
||||
ContainerNames: []string{containerName},
|
||||
Token: tokenStr,
|
||||
ContainerUUIDs: []string{c.UUID},
|
||||
AccessCode: accessCode,
|
||||
CreatedAt: time.Now().Format("2006-01-02 15:04:05"),
|
||||
}
|
||||
@@ -102,7 +128,7 @@ func HandleSubUserCreate(w http.ResponseWriter, r *http.Request) {
|
||||
config.SaveConfig()
|
||||
config.AddAuditLog("创建子用户", containerName, fmt.Sprintf("用户: %s", username), "admin")
|
||||
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Sub-user created", Data: subUser})
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Sub-user created", Data: newSubUserResponse(subUser, password)})
|
||||
}
|
||||
|
||||
// HandleSubUserLogin handles sub-user login
|
||||
@@ -121,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 := subUserContainerUUIDs(su.ContainerNames)
|
||||
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,
|
||||
@@ -142,6 +175,8 @@ func HandleSubUserLogin(w http.ResponseWriter, r *http.Request) {
|
||||
},
|
||||
})
|
||||
return
|
||||
} else {
|
||||
config.AddLoginLog(su.Username, clientIP, clientUA, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -166,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 := subUserContainerUUIDs(su.ContainerNames)
|
||||
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,
|
||||
@@ -195,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(),
|
||||
})
|
||||
@@ -224,18 +269,6 @@ func subUserAllowedContainers(r *http.Request) (subUserAccess, bool) {
|
||||
names: make(map[string]bool),
|
||||
uuids: make(map[string]bool),
|
||||
}
|
||||
if containerNames, ok := claims["container_names"].([]interface{}); ok {
|
||||
for _, cn := range containerNames {
|
||||
if name, ok := cn.(string); ok {
|
||||
allowed.names[name] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if containerNames, ok := claims["container_names"].([]string); ok {
|
||||
for _, name := range containerNames {
|
||||
allowed.names[name] = true
|
||||
}
|
||||
}
|
||||
if containerUUIDs, ok := claims["container_uuids"].([]interface{}); ok {
|
||||
for _, item := range containerUUIDs {
|
||||
if uuid, ok := item.(string); ok {
|
||||
@@ -251,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)
|
||||
@@ -273,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 {
|
||||
@@ -297,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
|
||||
@@ -311,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])
|
||||
@@ -322,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"})
|
||||
@@ -339,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))
|
||||
@@ -353,21 +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 allowed.names[task.ContainerName] || (task.Config.Name != "" && allowed.names[task.Config.Name]) {
|
||||
if isTaskAllowedForRequest(r, task) {
|
||||
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 allowed.names[c.Name] || (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 {
|
||||
@@ -377,6 +497,12 @@ func isSubUserContainerActionAllowed(action string, method string) bool {
|
||||
switch {
|
||||
case action == "usage" || action == "traffic" || action == "random-port":
|
||||
return method == http.MethodGet
|
||||
case action == "snapshots":
|
||||
return method == http.MethodGet || method == http.MethodPost
|
||||
case action == "snapshots/schedule":
|
||||
return method == http.MethodPost
|
||||
case strings.HasPrefix(action, "snapshots/"):
|
||||
return method == http.MethodDelete || method == http.MethodPost
|
||||
case action == "start" || action == "stop" || action == "restart" || action == "reinstall":
|
||||
return method == http.MethodPost
|
||||
case strings.HasPrefix(action, "port-mappings/"):
|
||||
@@ -386,16 +512,38 @@ func isSubUserContainerActionAllowed(action string, method string) bool {
|
||||
}
|
||||
}
|
||||
|
||||
func activeSubUserContainerUUIDs(su *config.SubUser) []string {
|
||||
uuids := make([]string, 0, len(su.ContainerUUIDs))
|
||||
for _, uuid := range su.ContainerUUIDs {
|
||||
if c := config.FindContainerByUUID(uuid); c != nil {
|
||||
uuids = appendUniqueString(uuids, c.UUID)
|
||||
}
|
||||
}
|
||||
if len(uuids) > 0 {
|
||||
return uuids
|
||||
}
|
||||
return subUserContainerUUIDs(su.ContainerNames)
|
||||
}
|
||||
|
||||
func subUserContainerUUIDs(containerNames []string) []string {
|
||||
uuids := make([]string, 0, len(containerNames))
|
||||
for _, name := range containerNames {
|
||||
if c := config.FindContainerByName(name); c != nil && c.UUID != "" {
|
||||
uuids = append(uuids, c.UUID)
|
||||
uuids = appendUniqueString(uuids, c.UUID)
|
||||
}
|
||||
}
|
||||
return uuids
|
||||
}
|
||||
|
||||
func appendUniqueString(values []string, value string) []string {
|
||||
for _, existing := range values {
|
||||
if existing == value {
|
||||
return values
|
||||
}
|
||||
}
|
||||
return append(values, value)
|
||||
}
|
||||
|
||||
func splitPath(path string) []string {
|
||||
parts := make([]string, 0)
|
||||
for _, p := range splitBy(path, "/") {
|
||||
@@ -420,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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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,13 +493,21 @@ func HandleSingleTaskAction(w http.ResponseWriter, r *http.Request, id int, acti
|
||||
templateID = c.Template
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
taskType = TaskReinstall
|
||||
default:
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Unknown action"})
|
||||
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",
|
||||
@@ -462,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"`
|
||||
}
|
||||
@@ -498,19 +564,59 @@ 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 err := validateContainerResourceRequest(req.Containers[i].VCPU, req.Containers[i].RAMMB, req.Containers[i].DiskGB); err != nil {
|
||||
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
|
||||
}
|
||||
if req.Containers[i].PortMappingCount < 0 {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: name + ": port mapping count cannot be negative"})
|
||||
return
|
||||
}
|
||||
if req.Containers[i].WantsNAT() && req.Containers[i].PortMappingCount < 2 {
|
||||
req.Containers[i].PortMappingCount = 2
|
||||
} else if !req.Containers[i].WantsNAT() {
|
||||
req.Containers[i].PortMappingCount = 0
|
||||
req.Containers[i].ExtraPorts = nil
|
||||
}
|
||||
if req.Containers[i].PortMappingCount > 64 {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: name + ": port mapping count cannot exceed 64"})
|
||||
return
|
||||
}
|
||||
if req.Containers[i].IPv4Count < 0 || req.Containers[i].IPv6Count < 0 {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: name + ": IP address count cannot be negative"})
|
||||
return
|
||||
}
|
||||
if req.Containers[i].IPv4Count > 64 || req.Containers[i].IPv6Count > 64 {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: name + ": IP address count cannot exceed 64"})
|
||||
return
|
||||
}
|
||||
if !req.Containers[i].AssignIPv4 && len(req.Containers[i].PublicIPv4s) == 0 {
|
||||
req.Containers[i].IPv4Count = 0
|
||||
}
|
||||
if !req.Containers[i].AssignIPv6 && len(req.Containers[i].IPv6Addresses) == 0 {
|
||||
req.Containers[i].IPv6Count = 0
|
||||
}
|
||||
if !hasRequestedNetwork(req.Containers[i]) {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: name + ": " + noNetworkSelectedMessage})
|
||||
return
|
||||
}
|
||||
if req.Containers[i].SnapshotLimit <= 0 {
|
||||
req.Containers[i].SnapshotLimit = config.DefaultSnapshotLimit
|
||||
}
|
||||
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})
|
||||
}
|
||||
|
||||
@@ -520,6 +626,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"`
|
||||
@@ -531,21 +641,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})
|
||||
}
|
||||
|
||||
@@ -555,13 +691,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))
|
||||
@@ -589,6 +734,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})
|
||||
@@ -625,6 +773,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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+1201
-136
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
}
|
||||
}
|
||||
+742
-138
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,175 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const letsEncryptLiveDir = "/etc/letsencrypt/live"
|
||||
|
||||
var dnsNamePattern = regexp.MustCompile(`^[A-Za-z0-9.-]+$`)
|
||||
|
||||
func SSLStorageDir() string {
|
||||
dataDir := ""
|
||||
if AppConfig != nil {
|
||||
dataDir = AppConfig.DataDir
|
||||
}
|
||||
if dataDir == "" {
|
||||
dataDir = getDataDir()
|
||||
}
|
||||
return filepath.Join(dataDir, "ssl")
|
||||
}
|
||||
|
||||
func UploadedSSLPaths() (string, string, error) {
|
||||
dir, err := safeSSLStorageDir()
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return filepath.Join(dir, "uploaded-fullchain.pem"), filepath.Join(dir, "uploaded-privkey.pem"), nil
|
||||
}
|
||||
|
||||
func SelfSignedSSLPaths() (string, string, error) {
|
||||
dir, err := safeSSLStorageDir()
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return filepath.Join(dir, "self-signed-fullchain.pem"), filepath.Join(dir, "self-signed-privkey.pem"), nil
|
||||
}
|
||||
|
||||
func LetsEncryptSSLPaths(target string) (string, string, error) {
|
||||
name, err := NormalizeSSLCertificateTarget(target)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
base := filepath.Join(letsEncryptLiveDir, name)
|
||||
return filepath.Join(base, "fullchain.pem"), filepath.Join(base, "privkey.pem"), nil
|
||||
}
|
||||
|
||||
func ResolveSSLConfigPaths(ssl SSLConfig) (string, string, error) {
|
||||
mode := NormalizeSSLMode(ssl.Mode)
|
||||
switch mode {
|
||||
case SSLModeUploaded:
|
||||
if ssl.CertPath != "" && ssl.KeyPath != "" {
|
||||
return ResolveSSLPathPair(ssl.CertPath, ssl.KeyPath)
|
||||
}
|
||||
return UploadedSSLPaths()
|
||||
case SSLModeSelfSigned:
|
||||
if ssl.CertPath != "" && ssl.KeyPath != "" {
|
||||
return ResolveSSLPathPair(ssl.CertPath, ssl.KeyPath)
|
||||
}
|
||||
return SelfSignedSSLPaths()
|
||||
case SSLModeLetsEncrypt:
|
||||
if strings.TrimSpace(ssl.Target) == "" && ssl.CertPath != "" && ssl.KeyPath != "" {
|
||||
return ResolveSSLPathPair(ssl.CertPath, ssl.KeyPath)
|
||||
}
|
||||
return LetsEncryptSSLPaths(ssl.Target)
|
||||
default:
|
||||
return "", "", fmt.Errorf("SSL is disabled")
|
||||
}
|
||||
}
|
||||
|
||||
func ResolveSSLPathPair(certPath, keyPath string) (string, string, error) {
|
||||
safeCertPath, err := ResolveSSLPath(certPath)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
safeKeyPath, err := ResolveSSLPath(keyPath)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return safeCertPath, safeKeyPath, nil
|
||||
}
|
||||
|
||||
func ResolveSSLPath(path string) (string, error) {
|
||||
cleaned, err := cleanAbsolutePath(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if isPathUnder(cleaned, SSLStorageDir()) || isPathUnder(cleaned, letsEncryptLiveDir) || isPathUnder(cleaned, "/etc/letsencrypt/archive") {
|
||||
return cleaned, nil
|
||||
}
|
||||
return "", fmt.Errorf("SSL path is outside allowed certificate directories")
|
||||
}
|
||||
|
||||
func ReadableFileStat(path string) (os.FileInfo, error) {
|
||||
safePath, err := ResolveSSLPath(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return os.Stat(safePath)
|
||||
}
|
||||
|
||||
func NormalizeSSLCertificateTarget(target string) (string, error) {
|
||||
target = strings.TrimSpace(strings.Trim(target, "[]"))
|
||||
if target == "" {
|
||||
return "", fmt.Errorf("SSL target is required")
|
||||
}
|
||||
if strings.Contains(target, "/") || strings.Contains(target, "\\") || strings.Contains(target, "..") {
|
||||
return "", fmt.Errorf("SSL target contains invalid path characters")
|
||||
}
|
||||
if ip := net.ParseIP(target); ip != nil {
|
||||
return ip.String(), nil
|
||||
}
|
||||
if len(target) > 253 || !dnsNamePattern.MatchString(target) {
|
||||
return "", fmt.Errorf("SSL target must be a valid IP address or DNS name")
|
||||
}
|
||||
labels := strings.Split(target, ".")
|
||||
for _, label := range labels {
|
||||
if label == "" || len(label) > 63 || strings.HasPrefix(label, "-") || strings.HasSuffix(label, "-") {
|
||||
return "", fmt.Errorf("SSL target must be a valid IP address or DNS name")
|
||||
}
|
||||
}
|
||||
return strings.ToLower(target), nil
|
||||
}
|
||||
|
||||
func safeSSLStorageDir() (string, error) {
|
||||
dir, err := cleanAbsolutePath(SSLStorageDir())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
dataDir := ""
|
||||
if AppConfig != nil {
|
||||
dataDir = AppConfig.DataDir
|
||||
}
|
||||
if dataDir == "" {
|
||||
dataDir = getDataDir()
|
||||
}
|
||||
if !isPathUnder(dir, dataDir) {
|
||||
return "", fmt.Errorf("SSL storage directory is outside the data directory")
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return dir, nil
|
||||
}
|
||||
|
||||
func cleanAbsolutePath(path string) (string, error) {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return "", fmt.Errorf("path is empty")
|
||||
}
|
||||
abs, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Clean(abs), nil
|
||||
}
|
||||
|
||||
func isPathUnder(path, root string) bool {
|
||||
cleanPath, err := cleanAbsolutePath(path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
cleanRoot, err := cleanAbsolutePath(root)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
rel, err := filepath.Rel(cleanRoot, cleanPath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)))
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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()
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+1365
-42
File diff suppressed because it is too large
Load Diff
+782
-161
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
}
|
||||
}
|
||||
+367
-32
@@ -2,8 +2,10 @@ package lxc
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"clicd/internal/config"
|
||||
)
|
||||
@@ -17,59 +19,223 @@ func (m *Manager) ApplyPortMappings(id int) error {
|
||||
if c.IP == "" {
|
||||
return fmt.Errorf("container has no IP")
|
||||
}
|
||||
EnsureAssignedPublicIPv4s(c.PublicIPv4s)
|
||||
tag := clicdTag(id)
|
||||
bridge := "lxcbr0"
|
||||
subnet := "10.0.3.0/24"
|
||||
if c.IsKVM() {
|
||||
bridge = "virbr0"
|
||||
subnet = "192.168.122.0/24"
|
||||
}
|
||||
|
||||
EnsureForwardRules()
|
||||
EnsureForwardRules(bridge)
|
||||
m.CleanPortMappings(id)
|
||||
deleteBridgeMasquerade(subnet)
|
||||
|
||||
for _, pm := range c.PortMappings {
|
||||
cmd := exec.Command("iptables",
|
||||
"-t", "nat",
|
||||
"-I", "PREROUTING", "1",
|
||||
"-p", pm.Protocol,
|
||||
"--dport", fmt.Sprintf("%d", pm.HostPort),
|
||||
"-j", "DNAT",
|
||||
"--to-destination", fmt.Sprintf("%s:%d", c.IP, pm.ContainerPort),
|
||||
"-m", "comment", "--comment", fmt.Sprintf("clicd-%s-%d", tag, pm.HostPort),
|
||||
)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: failed to apply port mapping %d->%s:%d: %v, output: %s\n",
|
||||
pm.HostPort, c.IP, pm.ContainerPort, err, string(output))
|
||||
continue
|
||||
for _, hostIP := range expandPortMappingHostIPs(c, pm) {
|
||||
args := []string{
|
||||
"-t", "nat",
|
||||
"-I", "PREROUTING", "1",
|
||||
"-p", pm.Protocol,
|
||||
}
|
||||
if hostIP != "" {
|
||||
args = append(args, "-d", hostIP)
|
||||
}
|
||||
args = append(args,
|
||||
"--dport", fmt.Sprintf("%d", pm.HostPort),
|
||||
"-j", "DNAT",
|
||||
"--to-destination", fmt.Sprintf("%s:%d", c.IP, pm.ContainerPort),
|
||||
"-m", "comment", "--comment", fmt.Sprintf("clicd-%s-%s-%d", tag, natRuleIPTag(hostIP), pm.HostPort),
|
||||
)
|
||||
cmd := exec.Command("iptables", args...)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: failed to apply port mapping %s:%d->%s:%d: %v, output: %s\n",
|
||||
displayHostIP(hostIP), pm.HostPort, c.IP, pm.ContainerPort, err, string(output))
|
||||
continue
|
||||
}
|
||||
fmt.Printf("Port mapping: %s:%d -> %s:%d\n", displayHostIP(hostIP), pm.HostPort, c.IP, pm.ContainerPort)
|
||||
}
|
||||
fmt.Printf("Port mapping: host:%d -> %s:%d\n", pm.HostPort, c.IP, pm.ContainerPort)
|
||||
}
|
||||
|
||||
if exec.Command("iptables", "-t", "nat", "-C", "POSTROUTING", "-s", "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()
|
||||
}
|
||||
applyIPv4EgressPolicy(c, bridge, subnet, tag)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func applyIPv4EgressPolicy(c *config.Container, bridge, subnet, tag string) {
|
||||
if c == nil || strings.TrimSpace(c.IP) == "" {
|
||||
return
|
||||
}
|
||||
if containerAllowsPublicIPv4Egress(c) {
|
||||
if _, ok := primaryPublicIPv4Assignment(c); ok {
|
||||
applyPublicIPv4SNAT(c, tag)
|
||||
return
|
||||
}
|
||||
ensureContainerMasquerade(c, tag)
|
||||
return
|
||||
}
|
||||
ensureIPv4EgressBlocked(c, bridge, subnet, tag)
|
||||
}
|
||||
|
||||
func containerAllowsPublicIPv4Egress(c *config.Container) bool {
|
||||
if c == nil {
|
||||
return false
|
||||
}
|
||||
if len(c.PublicIPv4s) > 0 {
|
||||
return true
|
||||
}
|
||||
return c.PortMappingLimit > 0 || len(c.PortMappings) > 0
|
||||
}
|
||||
|
||||
func ensureContainerMasquerade(c *config.Container, tag string) {
|
||||
args := []string{
|
||||
"-s", c.IP + "/32",
|
||||
"-m", "comment", "--comment", fmt.Sprintf("clicd-%s-masq", tag),
|
||||
"-j", "MASQUERADE",
|
||||
}
|
||||
if host := DetectPublicIPv4(); strings.TrimSpace(host.Interface) != "" {
|
||||
args = append([]string{"-o", strings.TrimSpace(host.Interface)}, args...)
|
||||
} else {
|
||||
args = append([]string{"-o", "eth+"}, args...)
|
||||
}
|
||||
ensureNATRule("POSTROUTING", args)
|
||||
}
|
||||
|
||||
func ensureIPv4EgressBlocked(c *config.Container, bridge, subnet, tag string) {
|
||||
args := []string{
|
||||
"-i", bridge,
|
||||
"-s", c.IP + "/32",
|
||||
"!", "-d", subnet,
|
||||
"-m", "comment", "--comment", fmt.Sprintf("clicd-%s-v4-egress-block", tag),
|
||||
"-j", "REJECT",
|
||||
}
|
||||
ensureFilterRule("FORWARD", args)
|
||||
}
|
||||
|
||||
func ensureNATRule(chain string, args []string) {
|
||||
check := append([]string{"-t", "nat", "-C", chain}, args...)
|
||||
if exec.Command("iptables", check...).Run() == nil {
|
||||
return
|
||||
}
|
||||
add := append([]string{"-t", "nat", "-I", chain, "1"}, args...)
|
||||
exec.Command("iptables", add...).Run()
|
||||
}
|
||||
|
||||
func ensureFilterRule(chain string, args []string) {
|
||||
check := append([]string{"-C", chain}, args...)
|
||||
if exec.Command("iptables", check...).Run() == nil {
|
||||
return
|
||||
}
|
||||
add := append([]string{"-I", chain, "1"}, args...)
|
||||
exec.Command("iptables", add...).Run()
|
||||
}
|
||||
|
||||
func deleteBridgeMasquerade(subnet string) {
|
||||
for exec.Command("iptables", "-t", "nat", "-D", "POSTROUTING", "-s", subnet, "-o", "eth+", "-j", "MASQUERADE").Run() == nil {
|
||||
}
|
||||
}
|
||||
|
||||
func applyPublicIPv4SNAT(c *config.Container, tag string) {
|
||||
if c == nil || strings.TrimSpace(c.IP) == "" {
|
||||
return
|
||||
}
|
||||
assignment, ok := primaryPublicIPv4Assignment(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
hostIP := strings.TrimSpace(assignment.Address)
|
||||
if hostIP == "" {
|
||||
return
|
||||
}
|
||||
iface := strings.TrimSpace(assignment.Interface)
|
||||
if iface == "" {
|
||||
if info, ok := publicIPv4InfoByAddress(hostIP); ok {
|
||||
iface = strings.TrimSpace(info.Interface)
|
||||
}
|
||||
}
|
||||
if iface == "" {
|
||||
if host := DetectPublicIPv4(); host.Interface != "" {
|
||||
iface = host.Interface
|
||||
}
|
||||
}
|
||||
args := []string{
|
||||
"-t", "nat",
|
||||
"-I", "POSTROUTING", "1",
|
||||
"-s", c.IP + "/32",
|
||||
}
|
||||
if iface != "" {
|
||||
args = append(args, "-o", iface)
|
||||
}
|
||||
args = append(args,
|
||||
"-m", "comment", "--comment", fmt.Sprintf("clicd-%s-snat-%s", tag, natRuleIPTag(hostIP)),
|
||||
"-j", "SNAT", "--to-source", hostIP,
|
||||
)
|
||||
if output, err := exec.Command("iptables", args...).CombinedOutput(); err != nil {
|
||||
fmt.Printf("Warning: failed to apply public IPv4 SNAT %s -> %s: %v, output: %s\n", c.IP, hostIP, err, string(output))
|
||||
}
|
||||
}
|
||||
|
||||
func primaryPublicIPv4Assignment(c *config.Container) (config.PublicIPv4Assignment, bool) {
|
||||
if c == nil {
|
||||
return config.PublicIPv4Assignment{}, false
|
||||
}
|
||||
for _, item := range c.PublicIPv4s {
|
||||
if strings.TrimSpace(item.Address) != "" {
|
||||
return item, true
|
||||
}
|
||||
}
|
||||
return config.PublicIPv4Assignment{}, false
|
||||
}
|
||||
|
||||
func clicdTag(id int) string { return "c" + strconv.Itoa(id) }
|
||||
|
||||
// EnsureForwardRules makes sure iptables FORWARD chain allows LXC bridge traffic
|
||||
func EnsureForwardRules() {
|
||||
func EnsureAllRunningPortMappings() {
|
||||
m := NewManager()
|
||||
for i := range config.AppConfig.Containers {
|
||||
c := &config.AppConfig.Containers[i]
|
||||
if c.Status != "running" || strings.TrimSpace(c.IP) == "" {
|
||||
continue
|
||||
}
|
||||
if err := m.ApplyPortMappings(c.ID); err != nil {
|
||||
fmt.Printf("Warning: failed to restore port mappings for %s: %v\n", c.Name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// EnsureForwardRules makes sure iptables FORWARD chain allows bridge traffic.
|
||||
func EnsureForwardRules(bridge string) {
|
||||
if bridge == "" {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
// CleanPortMappings removes all iptables rules for a container
|
||||
func (m *Manager) CleanPortMappings(id int) error {
|
||||
tag := clicdTag(id)
|
||||
for _, chain := range []string{"PREROUTING", "POSTROUTING"} {
|
||||
cmd := exec.Command("sh", "-c",
|
||||
fmt.Sprintf("iptables -t nat -L %s -n --line-numbers 2>/dev/null | grep 'clicd-%s-' | awk '{print $1}' | sort -rn | while read num; do iptables -t nat -D %s $num; done", chain, tag, chain))
|
||||
cmd.Run()
|
||||
}
|
||||
cmd := exec.Command("sh", "-c",
|
||||
fmt.Sprintf("iptables -t nat -L PREROUTING -n --line-numbers 2>/dev/null | grep 'clicd-%s' | awk '{print $1}' | sort -rn | while read num; do iptables -t nat -D PREROUTING $num; done", tag))
|
||||
fmt.Sprintf("iptables -S FORWARD 2>/dev/null | grep 'clicd-%s-' | sed 's/^-A /-D /' | while read rule; do iptables $rule; done", tag))
|
||||
cmd.Run()
|
||||
return nil
|
||||
}
|
||||
@@ -81,12 +247,26 @@ func SetupDefaultPortMappings(sshPort int) []config.PortMapping {
|
||||
}
|
||||
}
|
||||
|
||||
func DefaultPortMappingHostIP(assignments []config.PublicIPv4Assignment) string {
|
||||
if len(assignments) == 1 {
|
||||
return strings.TrimSpace(assignments[0].Address)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func defaultPortMappingHostIP(assignments []config.PublicIPv4Assignment) string {
|
||||
return DefaultPortMappingHostIP(assignments)
|
||||
}
|
||||
|
||||
// AddPortMapping adds a NAT rule to a container
|
||||
func (m *Manager) AddPortMapping(id int, pm config.PortMapping) ([]config.PortMapping, error) {
|
||||
c := config.FindContainer(id)
|
||||
if c == nil {
|
||||
return nil, fmt.Errorf("container not found: %d", id)
|
||||
}
|
||||
if c.PortMappingLimit <= 0 {
|
||||
return nil, fmt.Errorf("container has no IPv4 NAT port quota")
|
||||
}
|
||||
if c.PortMappingLimit > 0 && len(c.PortMappings) >= c.PortMappingLimit {
|
||||
return nil, fmt.Errorf("port mapping quota exceeded: %d/%d", len(c.PortMappings), c.PortMappingLimit)
|
||||
}
|
||||
@@ -155,18 +335,42 @@ func normalizePortMapping(c *config.Container, skipIndex int, pm config.PortMapp
|
||||
if pm.Protocol == "" {
|
||||
pm.Protocol = "tcp"
|
||||
}
|
||||
pm.Protocol = strings.ToLower(strings.TrimSpace(pm.Protocol))
|
||||
pm.HostIP = strings.TrimSpace(pm.HostIP)
|
||||
if pm.HostIP != "" {
|
||||
addr, err := netip.ParseAddr(pm.HostIP)
|
||||
if err != nil || !addr.Is4() {
|
||||
return pm, fmt.Errorf("host_ip must be a valid IPv4 address")
|
||||
}
|
||||
if !containerHasPublicIPv4(c, pm.HostIP) {
|
||||
return pm, fmt.Errorf("host_ip %s is not assigned to this container", pm.HostIP)
|
||||
}
|
||||
}
|
||||
if pm.Description == "" {
|
||||
pm.Description = fmt.Sprintf("Port-%d", pm.ContainerPort)
|
||||
}
|
||||
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)
|
||||
if portMappingsConflict(c, pm, c, existing) {
|
||||
return pm, fmt.Errorf("host port %d/%s already mapped on the same IPv4 in this container", pm.HostPort, pm.Protocol)
|
||||
}
|
||||
}
|
||||
// Check all other containers (LXC + KVM) for port conflicts
|
||||
for _, oc := range config.AppConfig.Containers {
|
||||
if oc.ID == c.ID {
|
||||
continue
|
||||
}
|
||||
for _, existing := range oc.PortMappings {
|
||||
oc := oc
|
||||
if portMappingsConflict(c, pm, &oc, existing) {
|
||||
return pm, fmt.Errorf("host port %d/%s already used on the same IPv4 by container %s (ID: %d)", pm.HostPort, pm.Protocol, oc.Name, oc.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
return pm, nil
|
||||
@@ -177,14 +381,30 @@ 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
|
||||
for _, hostIP := range expandPortMappingHostIPs(c, pm) {
|
||||
used[hostPortKey(hostIP, pm.HostPort)] = true
|
||||
}
|
||||
used[pm.ContainerPort] = true
|
||||
}
|
||||
// Also mark all other containers' host ports (LXC + KVM)
|
||||
for _, oc := range config.AppConfig.Containers {
|
||||
if oc.ID == c.ID {
|
||||
continue
|
||||
}
|
||||
for _, pm := range oc.PortMappings {
|
||||
oc := oc
|
||||
for _, hostIP := range expandPortMappingHostIPs(&oc, pm) {
|
||||
used[hostPortKey(hostIP, pm.HostPort)] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
ports := make([]int, 0, count)
|
||||
next := 20000
|
||||
for len(ports) < count {
|
||||
if !used[next] {
|
||||
hostIP := c.PrimaryPublicIPv4()
|
||||
if !used[hostPortKey(hostIP, next)] && !used[next] {
|
||||
ports = append(ports, next)
|
||||
}
|
||||
next++
|
||||
@@ -194,3 +414,118 @@ func allocateDefaultEqualPorts(c *config.Container, count int) []int {
|
||||
}
|
||||
return ports
|
||||
}
|
||||
|
||||
func HostPortAvailable(c *config.Container, hostIP string, hostPort int, protocol string) bool {
|
||||
if c == nil || hostPort <= 0 {
|
||||
return false
|
||||
}
|
||||
pm := config.PortMapping{HostIP: strings.TrimSpace(hostIP), HostPort: hostPort, Protocol: protocol}
|
||||
for _, existing := range c.PortMappings {
|
||||
if portMappingsConflict(c, pm, c, existing) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for _, oc := range config.AppConfig.Containers {
|
||||
if oc.ID == c.ID {
|
||||
continue
|
||||
}
|
||||
oc := oc
|
||||
for _, existing := range oc.PortMappings {
|
||||
if portMappingsConflict(c, pm, &oc, existing) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func expandPortMappingHostIPs(c *config.Container, pm config.PortMapping) []string {
|
||||
if strings.TrimSpace(pm.HostIP) != "" {
|
||||
return []string{strings.TrimSpace(pm.HostIP)}
|
||||
}
|
||||
if c != nil && len(c.PublicIPv4s) > 0 {
|
||||
values := make([]string, 0, len(c.PublicIPv4s))
|
||||
for _, item := range c.PublicIPv4s {
|
||||
if strings.TrimSpace(item.Address) != "" {
|
||||
values = append(values, strings.TrimSpace(item.Address))
|
||||
}
|
||||
}
|
||||
if len(values) > 0 {
|
||||
return values
|
||||
}
|
||||
}
|
||||
return []string{""}
|
||||
}
|
||||
|
||||
func containerHasPublicIPv4(c *config.Container, hostIP string) bool {
|
||||
if c == nil {
|
||||
return false
|
||||
}
|
||||
for _, item := range c.PublicIPv4s {
|
||||
if item.Address == hostIP {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func portMappingsConflict(aContainer *config.Container, a config.PortMapping, bContainer *config.Container, b config.PortMapping) bool {
|
||||
if a.HostPort != b.HostPort || !protocolsOverlap(a.Protocol, b.Protocol) {
|
||||
return false
|
||||
}
|
||||
aIPs := expandPortMappingHostIPs(aContainer, a)
|
||||
bIPs := expandPortMappingHostIPs(bContainer, b)
|
||||
for _, aIP := range aIPs {
|
||||
for _, bIP := range bIPs {
|
||||
if aIP == "" || bIP == "" || aIP == bIP {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func protocolsOverlap(a, b string) bool {
|
||||
a = strings.ToLower(strings.TrimSpace(a))
|
||||
b = strings.ToLower(strings.TrimSpace(b))
|
||||
if a == "" {
|
||||
a = "tcp"
|
||||
}
|
||||
if b == "" {
|
||||
b = "tcp"
|
||||
}
|
||||
if a == b || a == "all" || b == "all" {
|
||||
return true
|
||||
}
|
||||
return (a == "tcp+udp" && (b == "tcp" || b == "udp")) ||
|
||||
(b == "tcp+udp" && (a == "tcp" || a == "udp"))
|
||||
}
|
||||
|
||||
func natRuleIPTag(ip string) string {
|
||||
ip = strings.TrimSpace(ip)
|
||||
if ip == "" {
|
||||
return "any"
|
||||
}
|
||||
return strings.ReplaceAll(ip, ".", "_")
|
||||
}
|
||||
|
||||
func displayHostIP(ip string) string {
|
||||
if strings.TrimSpace(ip) == "" {
|
||||
return "host"
|
||||
}
|
||||
return ip
|
||||
}
|
||||
|
||||
func hostPortKey(hostIP string, port int) int {
|
||||
if hostIP == "" {
|
||||
return port
|
||||
}
|
||||
sum := 0
|
||||
for _, r := range hostIP {
|
||||
sum = sum*31 + int(r)
|
||||
}
|
||||
if sum < 0 {
|
||||
sum = -sum
|
||||
}
|
||||
return port + (sum % 1000000 * 100000)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
package lxc
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"clicd/internal/config"
|
||||
)
|
||||
|
||||
var snapshotMu sync.Mutex
|
||||
|
||||
func (m *Manager) CreateSnapshot(id int, createdBy string, scheduled bool, rotateLimit int) (config.Snapshot, error) {
|
||||
snapshotMu.Lock()
|
||||
defer snapshotMu.Unlock()
|
||||
|
||||
c := config.FindContainer(id)
|
||||
if c == nil {
|
||||
return config.Snapshot{}, fmt.Errorf("container not found: %d", id)
|
||||
}
|
||||
if scheduled && rotateLimit > 0 {
|
||||
for {
|
||||
existing := config.ContainerSnapshots(id)
|
||||
if len(existing) < rotateLimit {
|
||||
break
|
||||
}
|
||||
sortSnapshotsOldestFirst(existing)
|
||||
if err := m.deleteSnapshotLocked(existing[0]); err != nil {
|
||||
return config.Snapshot{}, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lxcName := c.LxcName()
|
||||
containerDir := filepath.Join(m.LxcPath, lxcName)
|
||||
if _, err := os.Stat(containerDir); err != nil {
|
||||
return config.Snapshot{}, fmt.Errorf("container storage not found: %v", err)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
snapshotID := fmt.Sprintf("snap-%d-%s", id, now.Format("20060102150405-000000000"))
|
||||
// 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
|
||||
}
|
||||
if err := os.MkdirAll(snapshotDir, 0700); err != nil {
|
||||
return config.Snapshot{}, err
|
||||
}
|
||||
|
||||
wasRunning, err := m.prepareContainerForColdCopy(id, lxcName, containerDir)
|
||||
if err != nil {
|
||||
os.RemoveAll(snapshotDir)
|
||||
return config.Snapshot{}, err
|
||||
}
|
||||
if wasRunning {
|
||||
defer func() {
|
||||
if err := m.StartContainer(id); err != nil {
|
||||
fmt.Printf("Warning: failed to restart %s after snapshot: %v\n", lxcName, err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
if err := copyTree(containerDir, snapshotDir); err != nil {
|
||||
os.RemoveAll(snapshotDir)
|
||||
return config.Snapshot{}, err
|
||||
}
|
||||
|
||||
snapshot := config.Snapshot{
|
||||
ID: snapshotID,
|
||||
ContainerID: c.ID,
|
||||
ContainerName: c.Name,
|
||||
LXCName: lxcName,
|
||||
CreatedAt: now.Format("2006-01-02 15:04:05"),
|
||||
CreatedBy: createdBy,
|
||||
Scheduled: scheduled,
|
||||
Path: snapshotDir,
|
||||
SizeBytes: dirSizeBytes(snapshotDir),
|
||||
}
|
||||
config.AddSnapshot(snapshot)
|
||||
return snapshot, nil
|
||||
}
|
||||
|
||||
func (m *Manager) DeleteSnapshot(id string) error {
|
||||
snapshotMu.Lock()
|
||||
defer snapshotMu.Unlock()
|
||||
|
||||
snapshot := config.FindSnapshot(id)
|
||||
if snapshot == nil {
|
||||
return fmt.Errorf("snapshot not found: %s", id)
|
||||
}
|
||||
return m.deleteSnapshotLocked(*snapshot)
|
||||
}
|
||||
|
||||
func (m *Manager) deleteSnapshotLocked(snapshot config.Snapshot) error {
|
||||
if snapshot.Path != "" {
|
||||
if err := safePathUnder(snapshot.Path, snapshotBaseDir()); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.RemoveAll(snapshot.Path); err != nil {
|
||||
return fmt.Errorf("failed to delete snapshot files: %v", err)
|
||||
}
|
||||
}
|
||||
config.RemoveSnapshot(snapshot.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) RestoreSnapshot(id string) error {
|
||||
snapshotMu.Lock()
|
||||
defer snapshotMu.Unlock()
|
||||
|
||||
snapshot := config.FindSnapshot(id)
|
||||
if snapshot == nil {
|
||||
return fmt.Errorf("snapshot not found: %s", id)
|
||||
}
|
||||
if snapshot.Path == "" {
|
||||
return fmt.Errorf("snapshot path is empty")
|
||||
}
|
||||
if err := safePathUnder(snapshot.Path, snapshotBaseDir()); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := os.Stat(snapshot.Path); err != nil {
|
||||
return fmt.Errorf("snapshot files not found: %v", err)
|
||||
}
|
||||
|
||||
c := config.FindContainer(snapshot.ContainerID)
|
||||
if c == nil {
|
||||
return fmt.Errorf("container not found: %d", snapshot.ContainerID)
|
||||
}
|
||||
lxcName := c.LxcName()
|
||||
containerDir := filepath.Join(m.LxcPath, lxcName)
|
||||
if err := safePathUnder(containerDir, m.LxcPath); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
wasRunning, err := m.prepareContainerForColdCopy(c.ID, lxcName, containerDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
backupDir := filepath.Join(m.LxcPath, fmt.Sprintf(".%s-restore-backup-%d", lxcName, time.Now().UnixNano()))
|
||||
if err := safePathUnder(backupDir, m.LxcPath); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(containerDir, backupDir); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("failed to move current container aside: %v", err)
|
||||
}
|
||||
|
||||
if err := copyTree(snapshot.Path, containerDir); err != nil {
|
||||
os.RemoveAll(containerDir)
|
||||
_ = os.Rename(backupDir, containerDir)
|
||||
return fmt.Errorf("failed to restore snapshot: %v", err)
|
||||
}
|
||||
_ = os.RemoveAll(backupDir)
|
||||
|
||||
config.UpdateContainerStatus(c.ID, "stopped")
|
||||
if wasRunning {
|
||||
return m.StartContainer(c.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) SetSnapshotSchedule(id int, enabled bool, intervalHours int, scheduleTime string, createdBy string) (*config.Container, error) {
|
||||
c := config.FindContainer(id)
|
||||
if c == nil {
|
||||
return nil, fmt.Errorf("container not found: %d", id)
|
||||
}
|
||||
if intervalHours < 24 {
|
||||
return nil, fmt.Errorf("snapshot schedule interval cannot be less than 24 hours")
|
||||
}
|
||||
if _, err := parseScheduleClock(scheduleTime); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.SnapshotScheduleEnabled = enabled
|
||||
c.SnapshotScheduleIntervalHours = intervalHours
|
||||
c.SnapshotScheduleTime = scheduleTime
|
||||
c.SnapshotScheduleCreatedBy = createdBy
|
||||
if enabled {
|
||||
c.SnapshotScheduleNextRun = nextSnapshotRun(time.Now(), intervalHours, scheduleTime).Format(time.RFC3339)
|
||||
} else {
|
||||
c.SnapshotScheduleNextRun = ""
|
||||
}
|
||||
if err := config.SaveConfig(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (m *Manager) StartSnapshotScheduler() {
|
||||
go func() {
|
||||
m.runDueSnapshotSchedules()
|
||||
ticker := time.NewTicker(time.Minute)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
m.runDueSnapshotSchedules()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (m *Manager) runDueSnapshotSchedules() {
|
||||
now := time.Now()
|
||||
containers := append([]config.Container(nil), config.AppConfig.Containers...)
|
||||
for _, c := range containers {
|
||||
if c.IsKVM() || !c.SnapshotScheduleEnabled {
|
||||
continue
|
||||
}
|
||||
nextRun, err := time.Parse(time.RFC3339, c.SnapshotScheduleNextRun)
|
||||
if err != nil || c.SnapshotScheduleNextRun == "" {
|
||||
nextRun = now
|
||||
}
|
||||
if now.Before(nextRun) {
|
||||
continue
|
||||
}
|
||||
createdBy := c.SnapshotScheduleCreatedBy
|
||||
if createdBy == "" {
|
||||
createdBy = "admin"
|
||||
}
|
||||
rotateLimit := 0
|
||||
if strings.HasPrefix(createdBy, "user:") {
|
||||
rotateLimit = config.ContainerSnapshotLimit(&c)
|
||||
}
|
||||
if _, err := m.CreateSnapshot(c.ID, createdBy, true, rotateLimit); err != nil {
|
||||
fmt.Printf("Warning: scheduled snapshot failed for %s: %v\n", c.Name, err)
|
||||
continue
|
||||
}
|
||||
if current := config.FindContainer(c.ID); current != nil {
|
||||
interval := current.SnapshotScheduleIntervalHours
|
||||
if interval < 24 {
|
||||
interval = 24
|
||||
}
|
||||
next := nextRun.Add(time.Duration(interval) * time.Hour)
|
||||
for !next.After(now) {
|
||||
next = next.Add(time.Duration(interval) * time.Hour)
|
||||
}
|
||||
current.SnapshotScheduleLastRun = now.Format(time.RFC3339)
|
||||
current.SnapshotScheduleNextRun = next.Format(time.RFC3339)
|
||||
config.SaveConfig()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func parseScheduleClock(value string) (time.Duration, error) {
|
||||
parts := strings.Split(value, ":")
|
||||
if len(parts) != 2 {
|
||||
return 0, fmt.Errorf("snapshot schedule time must be HH:MM")
|
||||
}
|
||||
hour, err := strconv.Atoi(parts[0])
|
||||
if err != nil || hour < 0 || hour > 23 {
|
||||
return 0, fmt.Errorf("snapshot schedule hour must be 00-23")
|
||||
}
|
||||
minute, err := strconv.Atoi(parts[1])
|
||||
if err != nil || minute < 0 || minute > 59 {
|
||||
return 0, fmt.Errorf("snapshot schedule minute must be 00-59")
|
||||
}
|
||||
return time.Duration(hour)*time.Hour + time.Duration(minute)*time.Minute, nil
|
||||
}
|
||||
|
||||
func nextSnapshotRun(from time.Time, intervalHours int, scheduleTime string) time.Time {
|
||||
clock, err := parseScheduleClock(scheduleTime)
|
||||
if err != nil {
|
||||
clock = 3 * time.Hour
|
||||
}
|
||||
midnight := time.Date(from.Year(), from.Month(), from.Day(), 0, 0, 0, 0, from.Location())
|
||||
next := midnight.Add(clock)
|
||||
interval := time.Duration(intervalHours) * time.Hour
|
||||
for !next.After(from) {
|
||||
next = next.Add(interval)
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
func (m *Manager) prepareContainerForColdCopy(id int, lxcName string, containerDir string) (bool, error) {
|
||||
status, _ := m.GetContainerStatus(lxcName)
|
||||
wasRunning := status == "running"
|
||||
if wasRunning {
|
||||
if err := m.StopContainer(id); err != nil {
|
||||
return false, err
|
||||
}
|
||||
time.Sleep(time.Second)
|
||||
} else if c := config.FindContainer(id); c != nil {
|
||||
m.CleanPortMappings(id)
|
||||
m.cleanupBandwidthLimit(c.LxcName())
|
||||
}
|
||||
rootfs := filepath.Join(containerDir, "rootfs")
|
||||
exec.Command("umount", "-R", "-l", rootfs).Run()
|
||||
m.detachContainerMounts(containerDir)
|
||||
m.detachContainerLoopDevices(containerDir)
|
||||
return wasRunning, nil
|
||||
}
|
||||
|
||||
func snapshotBaseDir() string {
|
||||
return filepath.Join(config.AppConfig.DataDir, "snapshots")
|
||||
}
|
||||
|
||||
func copyTree(src string, dst string) error {
|
||||
if err := os.MkdirAll(dst, 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
output, err := exec.Command("cp", "-a", "--sparse=always", "--reflink=auto", src+string(os.PathSeparator)+".", dst+string(os.PathSeparator)).CombinedOutput()
|
||||
if err != nil {
|
||||
output, err = exec.Command("cp", "-a", "--sparse=always", src+string(os.PathSeparator)+".", dst+string(os.PathSeparator)).CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("cp failed: %v, output: %s", err, string(output))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func dirSizeBytes(path string) int64 {
|
||||
out, err := exec.Command("du", "-s", "-B1", path).Output()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
parts := strings.Fields(string(out))
|
||||
if len(parts) == 0 {
|
||||
return 0
|
||||
}
|
||||
var size int64
|
||||
fmt.Sscanf(parts[0], "%d", &size)
|
||||
return size
|
||||
}
|
||||
|
||||
func safePathUnder(path string, base string) error {
|
||||
absPath, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
absBase, err := filepath.Abs(base)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if absPath == absBase || strings.HasPrefix(absPath, absBase+string(os.PathSeparator)) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("refusing unsafe path: %s", absPath)
|
||||
}
|
||||
|
||||
func sortSnapshotsOldestFirst(snapshots []config.Snapshot) {
|
||||
sort.SliceStable(snapshots, func(i, j int) bool {
|
||||
ti, _ := time.Parse("2006-01-02 15:04:05", snapshots[i].CreatedAt)
|
||||
tj, _ := time.Parse("2006-01-02 15:04:05", snapshots[j].CreatedAt)
|
||||
return ti.Before(tj)
|
||||
})
|
||||
}
|
||||
@@ -46,17 +46,17 @@ func GetTemplates() []Template {
|
||||
},
|
||||
{
|
||||
ID: "archlinux-current", Name: "Arch Linux",
|
||||
Distro: "archlinux", Release: "current", Arch: "amd64", Variant: "cloud",
|
||||
Distro: "archlinux", Release: "current", Arch: "amd64",
|
||||
Description: "Arch Linux (Rolling)",
|
||||
},
|
||||
{
|
||||
ID: "fedora-44", Name: "Fedora 44",
|
||||
Distro: "fedora", Release: "44", Arch: "amd64", Variant: "cloud",
|
||||
Distro: "fedora", Release: "44", Arch: "amd64",
|
||||
Description: "Fedora 44",
|
||||
},
|
||||
{
|
||||
ID: "rockylinux-10", Name: "Rocky Linux 10",
|
||||
Distro: "rockylinux", Release: "10", Arch: "amd64", Variant: "cloud",
|
||||
Distro: "rockylinux", Release: "10", Arch: "amd64",
|
||||
Description: "Rocky Linux 10",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"clicd/internal/api"
|
||||
"clicd/internal/config"
|
||||
"clicd/internal/lxc"
|
||||
)
|
||||
|
||||
// webFS holds embedded frontend files
|
||||
@@ -18,12 +19,19 @@ var webFS http.FileSystem
|
||||
// corsMiddleware adds CORS headers
|
||||
func corsMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
||||
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||
if origin := r.Header.Get("Origin"); origin != "" && isAllowedOrigin(origin, r.Host) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||
w.Header().Set("Vary", "Origin")
|
||||
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||
}
|
||||
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 {
|
||||
if origin := r.Header.Get("Origin"); origin != "" && !isAllowedOrigin(origin, r.Host) {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
@@ -32,28 +40,61 @@ func corsMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func isAllowedOrigin(origin string, requestHost string) bool {
|
||||
u, err := url.Parse(origin)
|
||||
if err != nil || u.Host == "" {
|
||||
return false
|
||||
}
|
||||
originHost := normalizeHost(u.Host)
|
||||
host := normalizeHost(requestHost)
|
||||
if originHost == host {
|
||||
return true
|
||||
}
|
||||
return isLoopbackHost(originHost) && isLoopbackHost(host)
|
||||
}
|
||||
|
||||
func normalizeHost(host string) string {
|
||||
if h, _, err := net.SplitHostPort(host); err == nil {
|
||||
return strings.ToLower(h)
|
||||
}
|
||||
return strings.ToLower(host)
|
||||
}
|
||||
|
||||
func isLoopbackHost(host string) bool {
|
||||
if host == "localhost" {
|
||||
return true
|
||||
}
|
||||
ip := net.ParseIP(host)
|
||||
return ip != nil && ip.IsLoopback()
|
||||
}
|
||||
|
||||
// setupRoutes configures API and static routes
|
||||
func setupRoutes(mux *http.ServeMux) {
|
||||
// API routes
|
||||
mux.HandleFunc("/api/login", corsMiddleware(api.HandleLogin))
|
||||
mux.HandleFunc("/api/language", corsMiddleware(api.HandleLanguage))
|
||||
mux.HandleFunc("/api/check-auth", corsMiddleware(api.AuthMiddleware(api.HandleCheckAuth)))
|
||||
mux.HandleFunc("/api/change-password", corsMiddleware(api.AdminMiddleware(api.HandleAdminPasswordChange)))
|
||||
mux.HandleFunc("/api/change-username", corsMiddleware(api.AdminMiddleware(api.HandleAdminUsernameChange)))
|
||||
mux.HandleFunc("/api/login-logs", corsMiddleware(api.AdminMiddleware(api.HandleLoginLogs)))
|
||||
mux.HandleFunc("/api/ssl", corsMiddleware(api.AdminMiddleware(api.HandleSSLSettings)))
|
||||
mux.HandleFunc("/api/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/ipv4-scan", corsMiddleware(api.AdminMiddleware(api.HandleRoutingIPv4Scan)))
|
||||
mux.HandleFunc("/api/routing", corsMiddleware(api.AdminMiddleware(api.HandleRouting)))
|
||||
mux.HandleFunc("/api/ipv6/status", corsMiddleware(api.AdminMiddleware(api.HandleIPv6Status)))
|
||||
mux.HandleFunc("/api/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)))
|
||||
@@ -61,18 +102,72 @@ 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/language", corsMiddleware(api.HandleLanguage))
|
||||
mux.HandleFunc("/api/v1/containers", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleContainers))))
|
||||
mux.HandleFunc("/api/v1/containers/list", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleContainerListAlias))))
|
||||
mux.HandleFunc("/api/v1/containers/", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleSingleContainer))))
|
||||
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/ipv4-scan", corsMiddleware(api.AuthMiddleware(api.HandleRoutingIPv4Scan)))
|
||||
mux.HandleFunc("/api/v1/routing", corsMiddleware(api.AuthMiddleware(api.HandleRouting)))
|
||||
mux.HandleFunc("/api/v1/ipv6/status", corsMiddleware(api.AuthMiddleware(api.HandleIPv6Status)))
|
||||
mux.HandleFunc("/api/v1/tasks", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleTasks))))
|
||||
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))
|
||||
|
||||
// Static files
|
||||
if webFS != nil {
|
||||
fs := http.FileServer(webFS)
|
||||
@@ -107,7 +202,6 @@ func setupRoutes(mux *http.ServeMux) {
|
||||
func Run() error {
|
||||
// Use embedded frontend files
|
||||
webFS = GetEmbeddedFS()
|
||||
startExpiryMonitor()
|
||||
|
||||
mux := http.NewServeMux()
|
||||
setupRoutes(mux)
|
||||
@@ -121,18 +215,63 @@ func Run() error {
|
||||
Handler: mux,
|
||||
}
|
||||
|
||||
if sslEnabled() {
|
||||
certPath, keyPath, err := config.ResolveSSLConfigPaths(config.AppConfig.SSL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
server.TLSConfig = &tls.Config{
|
||||
MinVersion: tls.VersionTLS12,
|
||||
GetCertificate: func(*tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||
safeCertPath, err := config.ResolveSSLPath(certPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
safeKeyPath, err := config.ResolveSSLPath(keyPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cert, err := tls.LoadX509KeyPair(safeCertPath, safeKeyPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &cert, nil
|
||||
},
|
||||
}
|
||||
log.Printf("CLICD Web Server SSL enabled on https://0.0.0.0:%d", config.AppConfig.Port)
|
||||
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 {
|
||||
return false
|
||||
}
|
||||
certPath, keyPath, err := config.ResolveSSLConfigPaths(ssl)
|
||||
if err != nil {
|
||||
log.Printf("SSL paths are invalid, falling back to HTTP: %v", err)
|
||||
return false
|
||||
}
|
||||
safeCertPath, err := config.ResolveSSLPath(certPath)
|
||||
if err != nil {
|
||||
log.Printf("SSL certificate path is not allowed, falling back to HTTP: %v", err)
|
||||
return false
|
||||
}
|
||||
safeKeyPath, err := config.ResolveSSLPath(keyPath)
|
||||
if err != nil {
|
||||
log.Printf("SSL private key path is not allowed, falling back to HTTP: %v", err)
|
||||
return false
|
||||
}
|
||||
if _, err := config.ReadableFileStat(safeCertPath); err != nil {
|
||||
log.Printf("SSL certificate is not readable, falling back to HTTP: %v", err)
|
||||
return false
|
||||
}
|
||||
if _, err := config.ReadableFileStat(safeKeyPath); err != nil {
|
||||
log.Printf("SSL private key is not readable, falling back to HTTP: %v", err)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package version
|
||||
|
||||
var (
|
||||
Version = "1.1.10"
|
||||
Repo = "MengMengCode/CLICD"
|
||||
)
|
||||
|
||||
func Current() string {
|
||||
if Version == "" {
|
||||
return "dev"
|
||||
}
|
||||
return Version
|
||||
}
|
||||
+45
-6
@@ -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,32 @@ 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")
|
||||
lxc.EnsureAllAssignedPublicIPv4s()
|
||||
|
||||
// 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 scanners.
|
||||
manager.StartSnapshotScheduler()
|
||||
kvmManager.StartSnapshotScheduler()
|
||||
|
||||
// Clean up stale container configs (LXC dir was deleted but config remains)
|
||||
config.CleanStaleContainers()
|
||||
lxc.EnsureAllRunningPortMappings()
|
||||
|
||||
// Pre-warm SSH for containers already running after host boot or service restart.
|
||||
manager.StartSSHWarmupScanner()
|
||||
@@ -95,8 +109,33 @@ func isWebPanelSystemdRunning() bool {
|
||||
func startWebPanelSystemd() {
|
||||
cmd := exec.Command("systemctl", "start", "clicd")
|
||||
if err := cmd.Run(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "警告: 自动启动 Web 面板失败: %v\n", err)
|
||||
fmt.Fprintf(os.Stderr, "%s: %v\n", mainT("警告: 自动启动 Web 面板失败"), err)
|
||||
} else {
|
||||
fmt.Println("Web 面板已自动启动")
|
||||
fmt.Println(mainT("Web 面板已自动启动"))
|
||||
}
|
||||
}
|
||||
|
||||
func mainT(text string) string {
|
||||
if !mainEnglish() {
|
||||
return text
|
||||
}
|
||||
switch text {
|
||||
case "警告: 自动启动 Web 面板失败":
|
||||
return "Warning: failed to auto-start web panel"
|
||||
case "Web 面板已自动启动":
|
||||
return "Web panel auto-started"
|
||||
default:
|
||||
return text
|
||||
}
|
||||
}
|
||||
|
||||
func mainEnglish() bool {
|
||||
lang := strings.ToLower(strings.TrimSpace(os.Getenv("CLICD_LANG")))
|
||||
if lang == "en" || strings.HasPrefix(lang, "en_") || strings.HasPrefix(lang, "en-") {
|
||||
return true
|
||||
}
|
||||
if lang == "zh" || strings.HasPrefix(lang, "zh_") || strings.HasPrefix(lang, "zh-") {
|
||||
return false
|
||||
}
|
||||
return config.AppConfig != nil && config.NormalizeLanguage(config.AppConfig.Language) == "en"
|
||||
}
|
||||
|
||||
@@ -52,7 +52,8 @@ go mod tidy
|
||||
go mod download
|
||||
|
||||
# Build for Linux amd64
|
||||
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -ldflags="-s -w" -o "$BUILD_DIR/clicd" .
|
||||
BUILD_VERSION="${CLICD_VERSION:-dev}"
|
||||
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -ldflags="-s -w -X clicd/internal/version.Version=${BUILD_VERSION}" -o "$BUILD_DIR/clicd" .
|
||||
|
||||
echo "Go backend built successfully"
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
.vitepress/cache/
|
||||
.vitepress/dist/
|
||||
.vitepress/.temp/
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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%;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import DefaultTheme from 'vitepress/theme'
|
||||
import './custom.css'
|
||||
|
||||
export default DefaultTheme
|
||||
@@ -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 连接。
|
||||
@@ -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` 用于生成静态文档。
|
||||
@@ -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 页面可以正常打开。
|
||||
@@ -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 集成”页面为准。
|
||||
@@ -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 连接,不要持久化保存。
|
||||
@@ -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
|
||||
```
|
||||
@@ -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、内网网段、用户名、密钥、票据或业务域名。
|
||||
@@ -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 的下载链接应指向微软官方资源,使用者需要自行获得合法授权。
|
||||
@@ -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、端口容量等运行时状态。
|
||||
@@ -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 等工具。
|
||||
@@ -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
|
||||
```
|
||||
|
||||
计划快照适合长期运行的容器。配额用于避免快照无限增长占满宿主机磁盘。
|
||||
@@ -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 等管理员功能不会开放给子用户。
|
||||
@@ -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 或票据。
|
||||
@@ -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
|
||||
```
|
||||
|
||||
卸载前请确认是否需要保留容器、镜像缓存、数据库和配置文件。
|
||||
@@ -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。
|
||||
@@ -0,0 +1,36 @@
|
||||
# 快速上手
|
||||
|
||||
下面是一条从安装后到创建第一台容器的常用路径。
|
||||
|
||||
## 1. 登录控制台
|
||||
|
||||
访问 `http://YOUR_SERVER_IP:8999`,使用管理员账号登录。
|
||||
|
||||
进入面板后先检查:
|
||||
|
||||
- 控制面板是否显示主机资源。
|
||||
- 镜像管理是否能列出模板。
|
||||
- 路由管理中 NAT 和 IPv6 状态是否符合宿主机预期。
|
||||
|
||||
## 2. 下载镜像
|
||||
|
||||
进入“镜像管理”,选择需要的模板并下载。宿主机资源较小时,可以优先选择 Alpine、Debian 这类轻量镜像。
|
||||
|
||||
镜像下载是异步任务,可以在任务队列中观察进度。
|
||||
|
||||
## 3. 创建容器
|
||||
|
||||
进入“容器管理”,点击创建:
|
||||
|
||||
- 选择虚拟化类型和模板。
|
||||
- 设置 CPU、内存、磁盘。
|
||||
- 设置流量限制和到期时间。
|
||||
- 如果需要外部访问,创建后到容器详情里添加 NAT 端口映射或分配 IPv6。
|
||||
|
||||
## 4. 打开终端
|
||||
|
||||
容器创建完成后,可以在详情页打开 WebSSH。KVM 虚拟机可使用 WebVNC 查看控制台。
|
||||
|
||||
## 5. 分发给子用户
|
||||
|
||||
如果需要把某个容器交给其他用户管理,进入“子用户管理”创建访问链接。子用户只会看到被授权的容器,并受到管理员配置的操作范围限制。
|
||||
@@ -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 面板版本号。
|
||||
@@ -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 管理接口。
|
||||
---
|
||||
@@ -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 数据库。
|
||||
- 容器配置。
|
||||
- 关键容器的快照或外部数据备份。
|
||||
@@ -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 地址段,并且路由、防火墙、邻居发现或代理配置正确。
|
||||
@@ -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 使用。
|
||||
Generated
+2628
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"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"
|
||||
},
|
||||
"overrides": {
|
||||
"vite": "6.4.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
cli.cd
|
||||
@@ -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 |
+9
-1
@@ -5,8 +5,16 @@
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>CLICD - LXC Container Manager</title>
|
||||
<script>
|
||||
(function() {
|
||||
var theme = localStorage.getItem('clicd_theme');
|
||||
if (theme === 'dark' || (!theme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
document.documentElement.classList.add('dark');
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body class="bg-white text-black">
|
||||
<body class="bg-white text-black dark:bg-gray-950 dark:text-white">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
|
||||
Generated
+566
-709
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "clicd-frontend",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.10",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
+12
-4
@@ -4,12 +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 }) {
|
||||
@@ -17,8 +21,8 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-white">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-black"></div>
|
||||
<div className="min-h-screen flex items-center justify-center bg-white dark:bg-gray-950">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-black dark:border-white"></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -55,10 +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 />} />
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import { useLanguage } from '../contexts/LanguageContext'
|
||||
import { shouldTranslateText, translateText } from '../utils/i18n'
|
||||
|
||||
const translatedTitleAttr = 'data-i18n-title-original'
|
||||
const translatedPlaceholderAttr = 'data-i18n-placeholder-original'
|
||||
const translatedAriaLabelAttr = 'data-i18n-aria-label-original'
|
||||
|
||||
const attributeNames = ['title', 'placeholder', 'aria-label'] as const
|
||||
const translatedTextNodes = new Set<Text>()
|
||||
const textOriginals = new WeakMap<Text, string>()
|
||||
const wholeTextSelector = 'button,a,span,label,option,th,td,p,h1,h2,h3,h4,small'
|
||||
|
||||
export default function AutoTranslate() {
|
||||
const { language } = useLanguage()
|
||||
const location = useLocation()
|
||||
|
||||
useEffect(() => {
|
||||
if (language === 'zh') {
|
||||
restoreTranslatedNodes(document.body)
|
||||
return
|
||||
}
|
||||
|
||||
translateNode(document.body)
|
||||
|
||||
const pending = new Set<Node>()
|
||||
let scheduled = false
|
||||
const flush = () => {
|
||||
scheduled = false
|
||||
const nodes = Array.from(pending)
|
||||
pending.clear()
|
||||
for (const node of nodes) {
|
||||
if (node.isConnected) translateNode(node)
|
||||
}
|
||||
}
|
||||
const schedule = (node: Node) => {
|
||||
pending.add(node)
|
||||
if (scheduled) return
|
||||
scheduled = true
|
||||
window.requestAnimationFrame(flush)
|
||||
}
|
||||
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
for (const mutation of mutations) {
|
||||
if (mutation.type === 'childList') {
|
||||
mutation.addedNodes.forEach(schedule)
|
||||
} else {
|
||||
schedule(mutation.target)
|
||||
}
|
||||
}
|
||||
})
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
characterData: true,
|
||||
attributes: true,
|
||||
attributeFilter: [...attributeNames],
|
||||
})
|
||||
return () => observer.disconnect()
|
||||
}, [language, location.pathname, location.search])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function translateNode(root: Node) {
|
||||
if (root.nodeType === Node.TEXT_NODE) {
|
||||
translateTextNode(root as Text)
|
||||
return
|
||||
}
|
||||
if (!(root instanceof Element)) return
|
||||
if (shouldSkipElement(root)) return
|
||||
|
||||
translateWholeTextElement(root)
|
||||
root.querySelectorAll<HTMLElement>(wholeTextSelector).forEach(translateWholeTextElement)
|
||||
translateElementAttributes(root)
|
||||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
|
||||
acceptNode(node) {
|
||||
if (!node.textContent || !shouldTranslateText(node.textContent)) return NodeFilter.FILTER_REJECT
|
||||
const parent = node.parentElement
|
||||
if (!parent || shouldSkipElement(parent)) {
|
||||
return NodeFilter.FILTER_REJECT
|
||||
}
|
||||
return NodeFilter.FILTER_ACCEPT
|
||||
},
|
||||
})
|
||||
|
||||
const nodes: Text[] = []
|
||||
while (walker.nextNode()) nodes.push(walker.currentNode as Text)
|
||||
for (const node of nodes) translateTextNode(node)
|
||||
root.querySelectorAll<HTMLElement>('[title], [placeholder], [aria-label]').forEach(translateElementAttributes)
|
||||
}
|
||||
|
||||
function translateTextNode(node: Text) {
|
||||
const original = node.textContent || ''
|
||||
if (!shouldTranslateText(original)) return
|
||||
const parent = node.parentElement
|
||||
if (!parent || shouldSkipElement(parent)) return
|
||||
const translated = translateText(original)
|
||||
if (translated === original) return
|
||||
textOriginals.set(node, original)
|
||||
translatedTextNodes.add(node)
|
||||
node.textContent = translated
|
||||
}
|
||||
|
||||
function translateWholeTextElement(el: Element) {
|
||||
if (!(el instanceof HTMLElement) || shouldSkipElement(el) || !isSimpleTextElement(el)) return
|
||||
const original = el.textContent || ''
|
||||
if (!shouldTranslateText(original)) return
|
||||
const translated = translateText(original)
|
||||
if (translated === original) return
|
||||
|
||||
const textNodes = directTextNodes(el)
|
||||
if (textNodes.length === 0) return
|
||||
textNodes.forEach((node, index) => {
|
||||
textOriginals.set(node, node.textContent || '')
|
||||
translatedTextNodes.add(node)
|
||||
node.textContent = index === 0 ? translated : ''
|
||||
})
|
||||
}
|
||||
|
||||
function directTextNodes(el: HTMLElement) {
|
||||
return Array.from(el.childNodes).filter((node): node is Text => node.nodeType === Node.TEXT_NODE)
|
||||
}
|
||||
|
||||
function translateElementAttributes(el: Element) {
|
||||
if (!(el instanceof HTMLElement)) return
|
||||
translateAttribute(el, 'title', translatedTitleAttr)
|
||||
translateAttribute(el, 'placeholder', translatedPlaceholderAttr)
|
||||
translateAttribute(el, 'aria-label', translatedAriaLabelAttr)
|
||||
}
|
||||
|
||||
function restoreTranslatedNodes(root: ParentNode) {
|
||||
for (const node of Array.from(translatedTextNodes)) {
|
||||
if (!node.isConnected) {
|
||||
translatedTextNodes.delete(node)
|
||||
continue
|
||||
}
|
||||
if (root instanceof Document || root.contains(node)) {
|
||||
node.textContent = textOriginals.get(node) || node.textContent
|
||||
translatedTextNodes.delete(node)
|
||||
}
|
||||
}
|
||||
root.querySelectorAll<HTMLElement>(`[${translatedTitleAttr}]`).forEach((el) => {
|
||||
el.setAttribute('title', el.getAttribute(translatedTitleAttr) || '')
|
||||
el.removeAttribute(translatedTitleAttr)
|
||||
})
|
||||
root.querySelectorAll<HTMLInputElement | HTMLTextAreaElement>(`[${translatedPlaceholderAttr}]`).forEach((el) => {
|
||||
el.setAttribute('placeholder', el.getAttribute(translatedPlaceholderAttr) || '')
|
||||
el.removeAttribute(translatedPlaceholderAttr)
|
||||
})
|
||||
root.querySelectorAll<HTMLElement>(`[${translatedAriaLabelAttr}]`).forEach((el) => {
|
||||
el.setAttribute('aria-label', el.getAttribute(translatedAriaLabelAttr) || '')
|
||||
el.removeAttribute(translatedAriaLabelAttr)
|
||||
})
|
||||
}
|
||||
|
||||
function translateAttribute(el: HTMLElement, attr: 'title' | 'placeholder' | 'aria-label', originalAttr: string) {
|
||||
const storedOriginal = el.getAttribute(originalAttr)
|
||||
const original = storedOriginal || el.getAttribute(attr) || ''
|
||||
if (!shouldTranslateText(original)) return
|
||||
const translated = translateText(original)
|
||||
if (translated === original) return
|
||||
if (!storedOriginal) {
|
||||
el.setAttribute(originalAttr, original)
|
||||
}
|
||||
if (el.getAttribute(attr) !== translated) {
|
||||
el.setAttribute(attr, translated)
|
||||
}
|
||||
}
|
||||
|
||||
function shouldSkipElement(el: Element) {
|
||||
return !!el.closest('script, style, code, pre, textarea, [data-no-translate]')
|
||||
}
|
||||
|
||||
function isSimpleTextElement(el: HTMLElement) {
|
||||
if (!el.matches(wholeTextSelector)) return false
|
||||
if (el.querySelector('input, textarea, select, button, table, pre, code, canvas, iframe')) return false
|
||||
const textNodes = directTextNodes(el)
|
||||
if (textNodes.length === 0) return false
|
||||
return Array.from(el.children).every((child) => child.tagName.toLowerCase() === 'svg')
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useLanguage } from '../contexts/LanguageContext'
|
||||
|
||||
export default function BrowserDialogTranslator() {
|
||||
const { t } = useLanguage()
|
||||
|
||||
useEffect(() => {
|
||||
const originalAlert = window.alert
|
||||
const originalConfirm = window.confirm
|
||||
window.alert = (message?: unknown) => originalAlert(t(String(message ?? '')))
|
||||
window.confirm = (message?: string) => originalConfirm(t(String(message ?? '')))
|
||||
return () => {
|
||||
window.alert = originalAlert
|
||||
window.confirm = originalConfirm
|
||||
}
|
||||
}, [t])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -2,15 +2,18 @@ import { useEffect, useMemo, useState, type ReactNode } from 'react'
|
||||
import { CalendarClock, X } from 'lucide-react'
|
||||
import { batchCreate, getIPv6Status, getEnabledImages, getHostInfo, CreateContainerRequest, HostInfo, IPv6Status, Template } from '../services/api'
|
||||
import { useDialog } from './Dialog'
|
||||
import { useLanguage, type Language } from '../contexts/LanguageContext'
|
||||
|
||||
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,29 +27,40 @@ const defaultForm: CreateContainerRequest = {
|
||||
io_speed_mbps: 0,
|
||||
extra_ports: [],
|
||||
port_mapping_count: 2,
|
||||
assign_nat: true,
|
||||
snapshot_limit: 1,
|
||||
assign_ipv4: false,
|
||||
ipv4_count: 1,
|
||||
public_ipv4s: [],
|
||||
assign_ipv6: false,
|
||||
ipv6_count: 1,
|
||||
ipv6_addresses: [],
|
||||
expires_at: '',
|
||||
}
|
||||
|
||||
export default function CreateContainerModal({ isOpen, onClose, onSuccess }: CreateContainerModalProps) {
|
||||
export default function CreateContainerModal({ isOpen, onClose, onSuccess, existingNames = [] }: CreateContainerModalProps) {
|
||||
const dialog = useDialog()
|
||||
const { language } = useLanguage()
|
||||
const networkText = createNetworkText[language]
|
||||
const [templates, setTemplates] = useState<Template[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [batchCount, setBatchCount] = useState(1)
|
||||
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)
|
||||
|
||||
@@ -66,35 +80,92 @@ 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 ipv6Prefixes = ipv6Status?.prefixes || []
|
||||
const ipv6Prefix = ipv6Prefixes.length > 1 ? `${ipv6Prefixes.length} prefixes configured` : (ipv6Prefixes[0]?.prefix || '')
|
||||
const publicIPv4s = hostInfo?.network.public_ipv4_addresses || []
|
||||
const ipv4Available = publicIPv4s.length > 0
|
||||
const manualIPv4s = form.public_ipv4s || []
|
||||
const maxVCPU = hostInfo?.cpu.cores || 64
|
||||
const maxRAMMB = hostInfo?.ram.total_mb ? Number(hostInfo.ram.total_mb) : undefined
|
||||
const maxDiskGB = hostInfo?.disk.total_gb ? Math.max(1, Math.floor(hostInfo.disk.total_gb)) : undefined
|
||||
const resourceErrors = validateResourceInputs(form, maxVCPU, maxRAMMB, maxDiskGB)
|
||||
const natEnabled = form.assign_nat !== false
|
||||
const natPortCount = natEnabled ? Math.max(2, form.port_mapping_count || 2) : 0
|
||||
|
||||
const autoPorts = useMemo(() => {
|
||||
const count = Math.max(2, form.port_mapping_count)
|
||||
if (!natEnabled) return []
|
||||
const count = natPortCount
|
||||
return Array.from({ length: count - 1 }, (_, index) => 22002 + index)
|
||||
}, [form.port_mapping_count])
|
||||
}, [natEnabled, natPortCount])
|
||||
|
||||
// SSH port preview (will be allocated sequentially, starting around 22000+)
|
||||
const sshPortPreview = 22000
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
if (!form.assign_ipv4 && !form.assign_ipv6 && form.assign_nat === false) {
|
||||
dialog.alert('提示', '请勾选任意一个可用网络')
|
||||
return
|
||||
}
|
||||
|
||||
const boundedForm = normalizeCreateForm(form)
|
||||
const wantsNAT = boundedForm.assign_nat !== false
|
||||
|
||||
// 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
|
||||
containers.push({ ...boundedForm, name, port_mapping_count: Math.max(2, boundedForm.port_mapping_count || 2), extra_ports: [] })
|
||||
const name = batchCount > 1 ? `${boundedForm.name}-${startIndex + i}` : boundedForm.name
|
||||
containers.push({
|
||||
...boundedForm,
|
||||
name,
|
||||
assign_nat: wantsNAT,
|
||||
port_mapping_count: wantsNAT ? Math.max(2, boundedForm.port_mapping_count || 2) : 0,
|
||||
snapshot_limit: Math.max(1, boundedForm.snapshot_limit || 3),
|
||||
ipv4_count: boundedForm.assign_ipv4 ? Math.max(1, boundedForm.ipv4_count || 1) : 0,
|
||||
ipv6_count: boundedForm.assign_ipv6 ? Math.max(1, boundedForm.ipv6_count || 1) : 0,
|
||||
extra_ports: [],
|
||||
})
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
@@ -130,27 +201,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) => (
|
||||
@@ -160,36 +251,196 @@ 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'}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!form.assign_ipv6}
|
||||
disabled={!ipv6Available}
|
||||
onChange={(event) => setForm({ ...form, assign_ipv6: event.target.checked })}
|
||||
className="mt-1"
|
||||
/>
|
||||
<span className="min-w-0">
|
||||
<span className="block font-medium text-gray-800">Public IPv6</span>
|
||||
<span className="block text-xs text-gray-500 truncate">
|
||||
{ipv6Available ? `Use ${ipv6Prefix}` : (ipv6Status?.reason || 'Checking IPv6 prefix...')}
|
||||
<div className={`rounded-md border px-3 py-2 text-sm ${ipv4Available ? 'border-gray-200 bg-white' : 'border-gray-200 bg-gray-50 text-gray-400'}`}>
|
||||
<label className="flex items-start gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!form.assign_ipv4}
|
||||
disabled={!ipv4Available}
|
||||
onChange={(event) => setForm({ ...form, assign_ipv4: event.target.checked, public_ipv4s: event.target.checked ? form.public_ipv4s : [] })}
|
||||
className="mt-1"
|
||||
/>
|
||||
<span className="min-w-0">
|
||||
<span className="block font-medium text-gray-800">{networkText.publicIPv4}</span>
|
||||
<span className="block text-xs text-gray-500">
|
||||
{ipv4Available ? formatAllocatableIPv4Count(publicIPv4s.length, language) : networkText.noAllocatableIPv4}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</label>
|
||||
{form.assign_ipv4 && (
|
||||
<div className="mt-3 space-y-3 pl-6">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<label className="flex items-center gap-2 text-xs text-gray-600">
|
||||
<input
|
||||
type="radio"
|
||||
checked={manualIPv4s.length === 0}
|
||||
onChange={() => setForm({ ...form, public_ipv4s: [] })}
|
||||
/>
|
||||
Auto assign
|
||||
</label>
|
||||
<Field label="IPv4 count">
|
||||
<NumberInput
|
||||
value={form.ipv4_count || 1}
|
||||
min={1}
|
||||
max={Math.max(1, publicIPv4s.length)}
|
||||
onChange={(value) => setForm({ ...form, ipv4_count: Math.max(1, Math.round(value || 1)) })}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="flex items-center gap-2 text-xs text-gray-600">
|
||||
<input
|
||||
type="radio"
|
||||
checked={manualIPv4s.length > 0}
|
||||
onChange={() => setForm({ ...form, public_ipv4s: publicIPv4s[0]?.address ? [publicIPv4s[0].address] : [], ipv4_count: 1 })}
|
||||
/>
|
||||
Manual select
|
||||
</label>
|
||||
{manualIPv4s.length > 0 && (
|
||||
<div className="grid gap-1.5 sm:grid-cols-2">
|
||||
{publicIPv4s.map((ip) => (
|
||||
<label key={`${ip.interface}-${ip.address}`} className="flex min-w-0 items-center gap-2 rounded border border-gray-200 px-2 py-1.5 text-xs text-gray-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={manualIPv4s.includes(ip.address)}
|
||||
onChange={(event) => {
|
||||
const next = event.target.checked
|
||||
? [...manualIPv4s, ip.address]
|
||||
: manualIPv4s.filter((value) => value !== ip.address)
|
||||
setForm({ ...form, public_ipv4s: next, ipv4_count: Math.max(1, next.length || 1) })
|
||||
}}
|
||||
/>
|
||||
<span className="truncate font-mono">{ip.address}</span>
|
||||
<span className="shrink-0 text-gray-400">{ip.interface}</span>
|
||||
{ip.gateway && <span className="shrink-0 text-gray-400">gw {ip.gateway}</span>}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={`rounded-md border px-3 py-2 text-sm ${ipv6Available ? 'border-gray-200 bg-white' : 'border-gray-200 bg-gray-50 text-gray-400'}`}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<label className="flex min-w-0 flex-1 items-start gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!form.assign_ipv6}
|
||||
disabled={!ipv6Available}
|
||||
onChange={(event) => setForm({ ...form, assign_ipv6: event.target.checked })}
|
||||
className="mt-1"
|
||||
/>
|
||||
<span className="min-w-0">
|
||||
<span className="block font-medium text-gray-800">{networkText.publicIPv6}</span>
|
||||
<span className="block text-xs text-gray-500 truncate">
|
||||
{ipv6Available ? `${networkText.use} ${ipv6Prefix}` : (ipv6Status?.reason || networkText.checkingIPv6Prefix)}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
{form.assign_ipv6 && (
|
||||
<span className="block w-24 shrink-0">
|
||||
<NumberInput
|
||||
value={form.ipv6_count || 1}
|
||||
min={1}
|
||||
max={64}
|
||||
onChange={(value) => setForm({ ...form, ipv6_count: Math.max(1, Math.round(value || 1)) })}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border border-gray-200 bg-white px-3 py-2 text-sm">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<label className="flex min-w-0 flex-1 items-start gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={natEnabled}
|
||||
onChange={(event) => {
|
||||
const checked = event.target.checked
|
||||
setForm({
|
||||
...form,
|
||||
assign_nat: checked,
|
||||
port_mapping_count: checked ? Math.max(2, form.port_mapping_count || 2) : 0,
|
||||
extra_ports: [],
|
||||
})
|
||||
}}
|
||||
className="mt-1"
|
||||
/>
|
||||
<span className="min-w-0">
|
||||
<span className="block font-medium text-gray-800">{networkText.publicNAT}</span>
|
||||
<span className="block text-xs text-gray-500">
|
||||
{natEnabled ? formatNATPortCount(natPortCount, language) : networkText.noNATPorts}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
{natEnabled && (
|
||||
<span className="block w-24 shrink-0">
|
||||
<NumberInput
|
||||
value={natPortCount}
|
||||
min={2}
|
||||
max={64}
|
||||
onChange={(value) => setForm({ ...form, port_mapping_count: Math.max(2, value || 2), assign_nat: true })}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{natEnabled && (
|
||||
<div className="mt-2 pl-6">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<span className="inline-flex px-2 py-1 bg-emerald-50 text-emerald-700 rounded text-xs font-mono">
|
||||
{isWindowsTemplate(form.template_id) ? 'RDP' : 'SSH'}: {sshPortPreview} -> {isWindowsTemplate(form.template_id) ? 3389 : 22}
|
||||
</span>
|
||||
{autoPorts.map((port) => (
|
||||
<span key={port} className="inline-flex px-2 py-1 bg-gray-100 text-gray-700 rounded text-xs font-mono">
|
||||
{port} -> {port}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<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 })} />
|
||||
@@ -229,23 +480,13 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess }: Cre
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Field label="NAT 端口映射数量">
|
||||
<Field label="子用户快照上限">
|
||||
<NumberInput
|
||||
value={form.port_mapping_count}
|
||||
min={2}
|
||||
max={64}
|
||||
onChange={(value) => setForm({ ...form, port_mapping_count: Math.max(2, value || 2) })}
|
||||
value={form.snapshot_limit}
|
||||
min={1}
|
||||
max={999}
|
||||
onChange={(value) => setForm({ ...form, snapshot_limit: Math.max(1, Math.round(value || 1)) })}
|
||||
/>
|
||||
<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} -> 22
|
||||
</span>
|
||||
{autoPorts.map((port) => (
|
||||
<span key={port} className="inline-flex px-2 py-1 bg-gray-100 text-gray-700 rounded text-xs font-mono">
|
||||
{port} -> {port}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<Field label="到期时间">
|
||||
@@ -294,43 +535,126 @@ 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)
|
||||
const wantsNAT = normalized.assign_nat !== false
|
||||
const wantsIPv4 = !!normalized.assign_ipv4
|
||||
const wantsIPv6 = !!normalized.assign_ipv6
|
||||
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),
|
||||
...normalized,
|
||||
vcpu: normalized.virtualization === 'kvm' ? Math.round(normalized.vcpu) : normalizeLXCvCPU(normalized.vcpu),
|
||||
ram_mb: Math.round(normalized.ram_mb),
|
||||
disk_gb: Math.round(normalized.disk_gb),
|
||||
assign_nat: wantsNAT,
|
||||
port_mapping_count: wantsNAT ? clampInt(normalized.port_mapping_count, 2, 64, 2) : 0,
|
||||
assign_ipv4: wantsIPv4,
|
||||
ipv4_count: wantsIPv4 ? clampInt(normalized.ipv4_count || 1, 1, 64, 1) : 0,
|
||||
public_ipv4s: wantsIPv4 ? (normalized.public_ipv4s || []) : [],
|
||||
assign_ipv6: wantsIPv6,
|
||||
ipv6_count: wantsIPv6 ? clampInt(normalized.ipv6_count || 1, 1, 64, 1) : 0,
|
||||
ipv6_addresses: wantsIPv6 ? (normalized.ipv6_addresses || []) : [],
|
||||
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) {
|
||||
@@ -338,5 +662,38 @@ function clampInt(value: number, min: number, max?: number, fallback = min) {
|
||||
return Math.min(Math.max(next, min), max ?? next)
|
||||
}
|
||||
|
||||
const createNetworkText = {
|
||||
zh: {
|
||||
publicIPv4: '公网 IPv4',
|
||||
noAllocatableIPv4: '未检测到可分配公网 IPv4',
|
||||
publicIPv6: '公网 IPv6',
|
||||
use: '使用',
|
||||
checkingIPv6Prefix: '正在检测 IPv6 前缀...',
|
||||
publicNAT: '公网 NAT',
|
||||
noNATPorts: '不分配 NAT 端口',
|
||||
},
|
||||
en: {
|
||||
publicIPv4: 'Public IPv4',
|
||||
noAllocatableIPv4: 'No allocatable public IPv4 detected',
|
||||
publicIPv6: 'Public IPv6',
|
||||
use: 'Use',
|
||||
checkingIPv6Prefix: 'Checking IPv6 prefix...',
|
||||
publicNAT: 'Public NAT',
|
||||
noNATPorts: 'No NAT ports will be assigned',
|
||||
},
|
||||
} as const
|
||||
|
||||
function formatAllocatableIPv4Count(count: number, language: Language) {
|
||||
return language === 'en'
|
||||
? `${count} allocatable address${count === 1 ? '' : 'es'} detected`
|
||||
: `检测到 ${count} 个可分配地址`
|
||||
}
|
||||
|
||||
function formatNATPortCount(count: number, language: Language) {
|
||||
return language === 'en'
|
||||
? `${count} NAT ports will be assigned`
|
||||
: `将分配 ${count} 个 NAT 端口`
|
||||
}
|
||||
|
||||
const inputClass =
|
||||
'w-full px-3 py-2 border border-gray-300 rounded-md text-sm text-black bg-white focus:outline-none focus:ring-2 focus:ring-black focus:border-black'
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useCallback, createContext, useContext, ReactNode } from 'react'
|
||||
import { AlertTriangle, CheckCircle, X } from 'lucide-react'
|
||||
import { useLanguage } from '../contexts/LanguageContext'
|
||||
|
||||
type DialogType = 'confirm' | 'alert'
|
||||
|
||||
@@ -20,6 +21,7 @@ const DialogContext = createContext<DialogContextType | undefined>(undefined)
|
||||
|
||||
export function DialogProvider({ children }: { children: ReactNode }) {
|
||||
const [dialog, setDialog] = useState<DialogState>({ open: false, type: 'alert', title: '', message: '' })
|
||||
const { t } = useLanguage()
|
||||
|
||||
const confirm = useCallback((title: string, message: string) => {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
@@ -50,7 +52,7 @@ export function DialogProvider({ children }: { children: ReactNode }) {
|
||||
}`}>
|
||||
{dialog.type === 'confirm' ? <AlertTriangle className="w-4 h-4" /> : <CheckCircle className="w-4 h-4" />}
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold text-black flex-1">{dialog.title}</h3>
|
||||
<h3 className="text-sm font-semibold text-black flex-1">{t(dialog.title)}</h3>
|
||||
{dialog.type === 'alert' && (
|
||||
<button onClick={() => close(true)} className="p-1 text-gray-400 hover:text-black rounded">
|
||||
<X className="w-4 h-4" />
|
||||
@@ -58,7 +60,7 @@ export function DialogProvider({ children }: { children: ReactNode }) {
|
||||
)}
|
||||
</div>
|
||||
<div className="px-5 py-4">
|
||||
<p className="text-sm text-gray-600">{dialog.message}</p>
|
||||
<p className="text-sm text-gray-600">{t(dialog.message)}</p>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 px-5 py-3 bg-gray-50 border-t border-gray-100">
|
||||
{dialog.type === 'confirm' && (
|
||||
@@ -66,7 +68,7 @@ export function DialogProvider({ children }: { children: ReactNode }) {
|
||||
onClick={() => close(false)}
|
||||
className="px-4 py-2 text-sm text-gray-700 hover:bg-gray-200 rounded-md transition-colors"
|
||||
>
|
||||
取消
|
||||
{t('取消')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
@@ -77,7 +79,7 @@ export function DialogProvider({ children }: { children: ReactNode }) {
|
||||
: 'bg-black text-white hover:bg-gray-800'
|
||||
}`}
|
||||
>
|
||||
{dialog.type === 'confirm' ? '确认' : '确定'}
|
||||
{dialog.type === 'confirm' ? t('确认') : t('确定')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { Outlet } from 'react-router-dom'
|
||||
import Sidebar from './Sidebar'
|
||||
import { useState } from 'react'
|
||||
import AutoTranslate from './AutoTranslate'
|
||||
import BrowserDialogTranslator from './BrowserDialogTranslator'
|
||||
|
||||
export default function Layout() {
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex">
|
||||
<div className="min-h-screen bg-gray-50 flex dark:bg-gray-950">
|
||||
<AutoTranslate />
|
||||
<BrowserDialogTranslator />
|
||||
<Sidebar collapsed={sidebarCollapsed} onToggle={() => setSidebarCollapsed(!sidebarCollapsed)} />
|
||||
<main className={`flex-1 transition-all duration-300 ${sidebarCollapsed ? 'ml-16' : 'ml-60'}`}>
|
||||
<div className="p-6">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ReactNode } from 'react'
|
||||
import { RefreshCw } from 'lucide-react'
|
||||
import { useTheme } from '../contexts/ThemeContext'
|
||||
|
||||
export type StatsRangeKey = '30m' | '1h' | '1d' | '1w'
|
||||
|
||||
@@ -45,17 +46,19 @@ export default function ResourceStatsPanel({
|
||||
charts: ResourceChartConfig[]
|
||||
}) {
|
||||
return (
|
||||
<section className="border border-gray-200 rounded-lg bg-white overflow-hidden">
|
||||
<div className="flex items-center justify-between gap-3 px-4 py-2.5 border-b border-gray-200 bg-white">
|
||||
<h2 className="text-sm font-semibold text-gray-950">统计信息</h2>
|
||||
<section className="border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-900 overflow-hidden">
|
||||
<div className="flex items-center justify-between gap-3 px-4 py-2.5 border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900">
|
||||
<h2 className="text-sm font-semibold text-gray-950 dark:text-white">统计信息</h2>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="inline-flex rounded border border-gray-200 bg-gray-50 p-0.5">
|
||||
<div className="inline-flex rounded border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 p-0.5">
|
||||
{(Object.keys(rangeLabels) as StatsRangeKey[]).map((item) => (
|
||||
<button
|
||||
key={item}
|
||||
onClick={() => onRangeChange(item)}
|
||||
className={`h-7 px-3 rounded text-xs font-medium transition-colors ${
|
||||
range === item ? 'bg-gray-800 text-white shadow-sm' : 'text-gray-500 hover:text-gray-900'
|
||||
range === item
|
||||
? 'bg-gray-800 text-white shadow-sm dark:bg-white dark:text-black'
|
||||
: 'text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{rangeLabels[item]}
|
||||
@@ -64,7 +67,7 @@ export default function ResourceStatsPanel({
|
||||
</div>
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
className="h-8 w-8 inline-flex items-center justify-center rounded border border-gray-200 text-gray-500 hover:bg-gray-50 hover:text-gray-900"
|
||||
className="h-8 w-8 inline-flex items-center justify-center rounded border border-gray-200 dark:border-gray-700 text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800 hover:text-gray-900 dark:hover:text-white"
|
||||
title="刷新"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
@@ -90,11 +93,11 @@ function DetailedChart({ chart, className }: { chart: ResourceChartConfig; class
|
||||
<div className={`p-4 ${className}`}>
|
||||
<div className="flex items-start justify-between gap-3 mb-2">
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5 text-sm font-semibold text-gray-950">
|
||||
<span className="text-gray-500">{chart.icon}</span>
|
||||
<div className="flex items-center gap-1.5 text-sm font-semibold text-gray-950 dark:text-white">
|
||||
<span className="text-gray-500 dark:text-gray-400">{chart.icon}</span>
|
||||
<span>{chart.title}</span>
|
||||
</div>
|
||||
{chart.detail && <p className="mt-0.5 text-[11px] text-gray-400">{chart.detail}</p>}
|
||||
{chart.detail && <p className="mt-0.5 text-[11px] text-gray-400 dark:text-gray-500">{chart.detail}</p>}
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3 text-right">
|
||||
<Stat label="当前" value={chart.formatValue(chart.current)} />
|
||||
@@ -115,8 +118,8 @@ function DetailedChart({ chart, className }: { chart: ResourceChartConfig; class
|
||||
function Stat({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="text-[10px] text-gray-400">{label}</div>
|
||||
<div className="text-xs font-semibold text-gray-900 tabular-nums whitespace-nowrap">{value}</div>
|
||||
<div className="text-[10px] text-gray-400 dark:text-gray-500">{label}</div>
|
||||
<div className="text-xs font-semibold text-gray-900 dark:text-gray-100 tabular-nums whitespace-nowrap">{value}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -132,6 +135,9 @@ function LineAreaChart({
|
||||
formatValue: (value: number) => string
|
||||
unitLabel?: string
|
||||
}) {
|
||||
const { theme } = useTheme()
|
||||
const isDark = theme === 'dark'
|
||||
|
||||
const width = 520
|
||||
const height = 150
|
||||
const left = 50
|
||||
@@ -158,12 +164,20 @@ function LineAreaChart({
|
||||
const yTicks = [1, 0.5, 0]
|
||||
const xTicks = [0, 0.5, 1]
|
||||
|
||||
// Dark mode colors
|
||||
const gridStroke = isDark ? '#374151' : '#e5e7eb'
|
||||
const gridStrokeV = isDark ? '#1f2937' : '#edf0f2'
|
||||
const axisStroke = isDark ? '#9ca3af' : '#888'
|
||||
const lineStroke = isDark ? '#f9fafb' : '#444'
|
||||
const gradientTop = isDark ? '#f9fafb' : '#555'
|
||||
const gradientBottom = isDark ? '#374151' : '#555'
|
||||
|
||||
return (
|
||||
<svg viewBox={`0 0 ${width} ${height}`} className="w-full h-[140px]" preserveAspectRatio="none">
|
||||
<defs>
|
||||
<linearGradient id="resource-chart-fill" x1="0" x2="0" y1="0" y2="1">
|
||||
<stop offset="0%" stopColor="#555" stopOpacity="0.25" />
|
||||
<stop offset="100%" stopColor="#555" stopOpacity="0.02" />
|
||||
<stop offset="0%" stopColor={gradientTop} stopOpacity="0.25" />
|
||||
<stop offset="100%" stopColor={gradientBottom} stopOpacity="0.02" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
@@ -171,8 +185,8 @@ function LineAreaChart({
|
||||
const y = top + (1 - tick) * innerHeight
|
||||
return (
|
||||
<g key={tick}>
|
||||
<line x1={left} y1={y} x2={left + innerWidth} y2={y} stroke="#e5e7eb" strokeDasharray="3 3" />
|
||||
<text x={left - 8} y={y + 3} textAnchor="end" fontSize="10" fill="#888">
|
||||
<line x1={left} y1={y} x2={left + innerWidth} y2={y} stroke={gridStroke} strokeDasharray="3 3" />
|
||||
<text x={left - 8} y={y + 3} textAnchor="end" fontSize="10" fill={axisStroke}>
|
||||
{formatValue(maxValue * tick)}
|
||||
</text>
|
||||
</g>
|
||||
@@ -184,8 +198,8 @@ function LineAreaChart({
|
||||
const ts = minTs + tick * span
|
||||
return (
|
||||
<g key={tick}>
|
||||
<line x1={x} y1={top} x2={x} y2={top + innerHeight} stroke="#edf0f2" strokeDasharray="3 3" />
|
||||
<text x={x} y={height - 5} textAnchor={tick === 0 ? 'start' : tick === 1 ? 'end' : 'middle'} fontSize="10" fill="#888">
|
||||
<line x1={x} y1={top} x2={x} y2={top + innerHeight} stroke={gridStrokeV} strokeDasharray="3 3" />
|
||||
<text x={x} y={height - 5} textAnchor={tick === 0 ? 'start' : tick === 1 ? 'end' : 'middle'} fontSize="10" fill={axisStroke}>
|
||||
{formatTime(ts)}
|
||||
</text>
|
||||
</g>
|
||||
@@ -193,15 +207,15 @@ function LineAreaChart({
|
||||
})}
|
||||
|
||||
{unitLabel && (
|
||||
<text x={left - 45} y={top + 10} fontSize="10" fill="#888">
|
||||
<text x={left - 45} y={top + 10} fontSize="10" fill={axisStroke}>
|
||||
{unitLabel}
|
||||
</text>
|
||||
)}
|
||||
|
||||
<line x1={left} y1={top} x2={left} y2={top + innerHeight} stroke="#888" />
|
||||
<line x1={left} y1={top + innerHeight} x2={left + innerWidth} y2={top + innerHeight} stroke="#888" />
|
||||
<line x1={left} y1={top} x2={left} y2={top + innerHeight} stroke={axisStroke} />
|
||||
<line x1={left} y1={top + innerHeight} x2={left + innerWidth} y2={top + innerHeight} stroke={axisStroke} />
|
||||
<polygon points={area} fill="url(#resource-chart-fill)" />
|
||||
<polyline points={line} fill="none" stroke="#444" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<polyline points={line} fill="none" stroke={lineStroke} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { useTheme } from '../contexts/ThemeContext'
|
||||
|
||||
interface RingStatProps {
|
||||
value: number
|
||||
@@ -10,10 +11,16 @@ interface RingStatProps {
|
||||
}
|
||||
|
||||
export function RingStat({ value, max = 100, label, subLabel, size = 120, strokeWidth = 8 }: RingStatProps) {
|
||||
const { theme } = useTheme()
|
||||
const isDark = theme === 'dark'
|
||||
|
||||
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'
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
@@ -25,7 +32,7 @@ export function RingStat({ value, max = 100, label, subLabel, size = 120, stroke
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="#f3f4f6"
|
||||
stroke={bgStroke}
|
||||
strokeWidth={strokeWidth}
|
||||
/>
|
||||
{/* Progress ring */}
|
||||
@@ -34,7 +41,7 @@ export function RingStat({ value, max = 100, label, subLabel, size = 120, stroke
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="#000000"
|
||||
stroke={progressStroke}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={circumference}
|
||||
@@ -44,12 +51,12 @@ 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">{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">
|
||||
<div className="text-sm font-medium text-gray-800">{label}</div>
|
||||
{subLabel && <div className="text-xs text-gray-400 mt-0.5">{subLabel}</div>}
|
||||
<div className="text-sm font-medium text-gray-800 dark:text-gray-200">{label}</div>
|
||||
{subLabel && <div className="text-xs text-gray-400 dark:text-gray-500 mt-0.5">{subLabel}</div>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -66,7 +73,6 @@ interface RingStatsProps {
|
||||
swapUsed?: number
|
||||
swapTotal?: number
|
||||
loadPercent: number
|
||||
loadStatus: string
|
||||
diskPercent: number
|
||||
diskUsed: number
|
||||
diskTotal: number
|
||||
@@ -83,7 +89,6 @@ export default function RingStats({
|
||||
swapUsed = 0,
|
||||
swapTotal = 0,
|
||||
loadPercent,
|
||||
loadStatus,
|
||||
diskPercent,
|
||||
diskUsed,
|
||||
diskTotal,
|
||||
@@ -96,8 +101,8 @@ export default function RingStats({
|
||||
const hasSwap = swapTotal > 0
|
||||
|
||||
return (
|
||||
<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="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg p-5">
|
||||
<h2 className="text-sm font-semibold text-black dark:text-white mb-4">状态</h2>
|
||||
<div className={`grid ${hasSwap ? 'grid-cols-5' : 'grid-cols-4'} gap-3`}>
|
||||
<RingStat
|
||||
value={cpuPercent}
|
||||
@@ -118,8 +123,8 @@ export default function RingStats({
|
||||
)}
|
||||
<RingStat
|
||||
value={loadPercent}
|
||||
max={Infinity}
|
||||
label="负载"
|
||||
subLabel={loadStatus}
|
||||
/>
|
||||
<RingStat
|
||||
value={diskPercent}
|
||||
|
||||
@@ -1,18 +1,27 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Code2,
|
||||
Cpu,
|
||||
Camera,
|
||||
LayoutDashboard,
|
||||
LogOut,
|
||||
Moon,
|
||||
Package,
|
||||
Route,
|
||||
ScrollText,
|
||||
Server,
|
||||
Settings2,
|
||||
|
||||
ShieldAlert,
|
||||
Sun,
|
||||
UserCog,
|
||||
} from 'lucide-react'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { useLanguage } from '../contexts/LanguageContext'
|
||||
import { useTheme } from '../contexts/ThemeContext'
|
||||
import { getVersion } from '../services/api'
|
||||
import AppIcon from './AppIcon'
|
||||
|
||||
interface SidebarProps {
|
||||
@@ -20,46 +29,90 @@ 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>
|
||||
)
|
||||
}
|
||||
|
||||
function LanguageIcon({ className = '' }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<path
|
||||
d="M213.333333 640v85.333333a85.333333 85.333333 0 0 0 78.933334 85.12L298.666667 810.666667h128v85.333333H298.666667a170.666667 170.666667 0 0 1-170.666667-170.666667v-85.333333h85.333333z m554.666667-213.333333l187.733333 469.333333h-91.946666l-51.242667-128h-174.506667l-51.157333 128h-91.904L682.666667 426.666667h85.333333z m-42.666667 123.093333L672.128 682.666667h106.325333L725.333333 549.76zM341.333333 85.333333v85.333334h170.666667v298.666666H341.333333v128H256v-128H85.333333V170.666667h170.666667V85.333333h85.333333z m384 42.666667a170.666667 170.666667 0 0 1 170.666667 170.666667v85.333333h-85.333333V298.666667a85.333333 85.333333 0 0 0-85.333334-85.333334h-128V128h128zM256 256H170.666667v128h85.333333V256z m170.666667 0H341.333333v128h85.333334V256z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const { logout, isSubUser } = useAuth()
|
||||
const { theme, toggleTheme } = useTheme()
|
||||
const { language, toggleLanguage, t } = useLanguage()
|
||||
const [version, setVersion] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
getVersion()
|
||||
.then(res => {
|
||||
if (res.data?.data?.version) {
|
||||
setVersion(res.data.data.version)
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
const isContainerPage =
|
||||
location.pathname.startsWith('/containers') ||
|
||||
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')
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={`fixed left-0 top-0 h-full bg-white border-r border-gray-200 flex flex-col transition-all duration-300 z-30 ${
|
||||
className={`fixed left-0 top-0 h-full bg-white border-r border-gray-200 flex flex-col transition-all duration-300 z-30 dark:bg-gray-900 dark:border-gray-700 ${
|
||||
collapsed ? 'w-16' : 'w-60'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between h-14 px-4 border-b border-gray-200">
|
||||
<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">
|
||||
<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">CLICD</span>
|
||||
<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">
|
||||
<div className="w-7 h-7 flex items-center justify-center mx-auto">
|
||||
<AppIcon className="w-5 h-5" />
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="p-1 rounded hover:bg-gray-100 text-gray-500"
|
||||
title="切换侧边栏"
|
||||
className="p-1 rounded hover:bg-gray-100 text-gray-500 dark:hover:bg-gray-800 dark:text-gray-400"
|
||||
title={t('切换侧边栏')}
|
||||
>
|
||||
{collapsed ? (
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
@@ -75,8 +128,8 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
|
||||
onClick={() => navigate('/')}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
|
||||
location.pathname === '/'
|
||||
? 'bg-black text-white'
|
||||
: 'text-gray-700 hover:bg-gray-100'
|
||||
? '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'
|
||||
}`}
|
||||
>
|
||||
<LayoutDashboard className="w-4 h-4" />
|
||||
@@ -88,8 +141,8 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
|
||||
onClick={() => navigate('/containers')}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
|
||||
isContainerPage
|
||||
? 'bg-black text-white'
|
||||
: 'text-gray-700 hover:bg-gray-100'
|
||||
? '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'
|
||||
}`}
|
||||
>
|
||||
<Server className="w-4 h-4" />
|
||||
@@ -101,8 +154,8 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
|
||||
onClick={() => navigate('/images')}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
|
||||
isImagesPage
|
||||
? 'bg-black text-white'
|
||||
: 'text-gray-700 hover:bg-gray-100'
|
||||
? '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'
|
||||
}`}
|
||||
>
|
||||
<Package className="w-4 h-4" />
|
||||
@@ -112,60 +165,96 @@ 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'
|
||||
: 'text-gray-700 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<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 ${
|
||||
isSecurityPage
|
||||
? 'bg-black text-white'
|
||||
: 'text-gray-700 hover:bg-gray-100'
|
||||
? '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'
|
||||
}`}
|
||||
>
|
||||
<ShieldAlert className="w-4 h-4" />
|
||||
{!collapsed && <span>安全告警</span>}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => navigate('/snapshots')}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
|
||||
isSnapshotsPage
|
||||
? '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'
|
||||
}`}
|
||||
>
|
||||
<Camera className="w-4 h-4" />
|
||||
{!collapsed && <span>快照管理</span>}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => navigate('/routing')}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
|
||||
isRoutingPage
|
||||
? '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'
|
||||
}`}
|
||||
>
|
||||
<Route className="w-4 h-4" />
|
||||
{!collapsed && <span>路由管理</span>}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => navigate('/audit-logs')}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
|
||||
isAuditLogsPage
|
||||
? 'bg-black text-white'
|
||||
: 'text-gray-700 hover:bg-gray-100'
|
||||
? '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'
|
||||
}`}
|
||||
>
|
||||
<ScrollText className="w-4 h-4" />
|
||||
{!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 ${
|
||||
isApiIntegrationPage
|
||||
? 'bg-black text-white'
|
||||
: 'text-gray-700 hover:bg-gray-100'
|
||||
? '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'
|
||||
}`}
|
||||
>
|
||||
<Code2 className="w-4 h-4" />
|
||||
{!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 ${
|
||||
isSettingsPage
|
||||
? 'bg-black text-white'
|
||||
: 'text-gray-700 hover:bg-gray-100'
|
||||
? '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" />
|
||||
@@ -175,10 +264,66 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
|
||||
)}
|
||||
</nav>
|
||||
|
||||
<div className="border-t border-gray-200 p-2">
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 p-2 space-y-1">
|
||||
{/* Theme Toggle */}
|
||||
<div className={collapsed ? 'space-y-1' : 'flex items-center gap-1'}>
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className={`${collapsed ? 'w-full justify-center' : 'flex-1'} flex items-center gap-3 px-3 py-2.5 rounded-md text-sm text-gray-600 hover:bg-gray-100 transition-colors dark:text-gray-400 dark:hover:bg-gray-800`}
|
||||
title={t(theme === 'dark' ? '切换亮色模式' : '切换暗黑模式')}
|
||||
>
|
||||
{theme === 'dark' ? (
|
||||
<Sun className="w-4 h-4" />
|
||||
) : (
|
||||
<Moon className="w-4 h-4" />
|
||||
)}
|
||||
{!collapsed && <span>{theme === 'dark' ? '亮色模式' : '暗黑模式'}</span>}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => { void toggleLanguage() }}
|
||||
className={`${collapsed ? 'w-full' : 'w-10'} flex items-center justify-center rounded-md px-2 py-2.5 text-sm text-gray-600 hover:bg-gray-100 transition-colors dark:text-gray-400 dark:hover:bg-gray-800`}
|
||||
title={language === 'en' ? '切换中文' : 'Switch to English'}
|
||||
>
|
||||
<LanguageIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Version */}
|
||||
{version && (
|
||||
<div className={`px-3 py-2 text-xs text-gray-400 dark:text-gray-500 ${collapsed ? 'text-center' : ''}`}>
|
||||
{collapsed ? (
|
||||
<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>
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Logout */}
|
||||
<button
|
||||
onClick={logout}
|
||||
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm text-gray-600 hover:bg-gray-100 transition-colors"
|
||||
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm text-gray-600 hover:bg-gray-100 transition-colors dark:text-gray-400 dark:hover:bg-gray-800"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
{!collapsed && <span>退出登录</span>}
|
||||
|
||||
@@ -19,11 +19,10 @@ export default function WebSSHViewer({ containerName, onClose }: WebSSHViewerPro
|
||||
const [status, setStatus] = useState<'connecting' | 'preparing' | 'connected' | 'disconnected' | 'error'>('connecting')
|
||||
const [errorMsg, setErrorMsg] = useState('')
|
||||
|
||||
const buildWebSSHUrl = (ticket: string) => {
|
||||
const buildWebSSHUrl = () => {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
const params = new URLSearchParams({
|
||||
container: containerName,
|
||||
ticket,
|
||||
})
|
||||
return `${protocol}//${window.location.host}/api/ssh?${params.toString()}`
|
||||
}
|
||||
@@ -106,7 +105,7 @@ export default function WebSSHViewer({ containerName, onClose }: WebSSHViewerPro
|
||||
return
|
||||
}
|
||||
|
||||
const ws = new WebSocket(buildWebSSHUrl(ticket))
|
||||
const ws = new WebSocket(buildWebSSHUrl(), [`clicd-ticket.${ticket}`])
|
||||
ws.binaryType = 'arraybuffer'
|
||||
wsRef.current = ws
|
||||
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Monitor, RefreshCw, Send, X } from 'lucide-react'
|
||||
import RFBModule from '@novnc/novnc/lib/rfb'
|
||||
import { createVNCTicket, getWebVNCUrl } from '../services/api'
|
||||
|
||||
type RFBConstructor = new (
|
||||
target: HTMLElement,
|
||||
url: string,
|
||||
options?: { credentials?: Record<string, string>; shared?: boolean; repeaterID?: string; wsProtocols?: string[] }
|
||||
) => RFBInstance
|
||||
|
||||
interface RFBInstance extends EventTarget {
|
||||
scaleViewport: boolean
|
||||
resizeSession: boolean
|
||||
focusOnClick: boolean
|
||||
viewOnly: boolean
|
||||
qualityLevel: number
|
||||
compressionLevel: number
|
||||
background: string
|
||||
disconnect(): void
|
||||
sendCtrlAltDel(): void
|
||||
}
|
||||
|
||||
const RFB = resolveRFBConstructor(RFBModule)
|
||||
|
||||
function resolveRFBConstructor(moduleValue: unknown): RFBConstructor {
|
||||
if (typeof moduleValue === 'function') {
|
||||
return moduleValue as RFBConstructor
|
||||
}
|
||||
const maybeDefault = (moduleValue as { default?: unknown })?.default
|
||||
if (typeof maybeDefault === 'function') {
|
||||
return maybeDefault as RFBConstructor
|
||||
}
|
||||
throw new Error('noVNC RFB constructor is unavailable')
|
||||
}
|
||||
|
||||
interface WebVNCViewerProps {
|
||||
containerName: string
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export default function WebVNCViewer({ containerName, onClose }: WebVNCViewerProps) {
|
||||
const screenRef = useRef<HTMLDivElement>(null)
|
||||
const rfbRef = useRef<RFBInstance | 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="flex h-full flex-col overflow-hidden rounded-lg border border-gray-200 bg-white">
|
||||
<div className="flex shrink-0 items-center justify-between border-b border-gray-200 bg-gray-50 px-4 py-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Monitor className="h-4 w-4 text-gray-600" />
|
||||
<span className="text-sm font-medium text-black">WebVNC - {containerName}</span>
|
||||
{status === 'connected' && <span className="rounded bg-green-100 px-1.5 py-0.5 text-xs text-green-700">已连接</span>}
|
||||
{status === 'connecting' && <span className="rounded bg-yellow-100 px-1.5 py-0.5 text-xs text-yellow-700">连接中...</span>}
|
||||
{status === 'disconnected' && <span className="rounded bg-gray-100 px-1.5 py-0.5 text-xs text-gray-600">已断开</span>}
|
||||
{status === 'error' && <span className="rounded bg-red-100 px-1.5 py-0.5 text-xs text-red-700">连接失败</span>}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button onClick={() => rfbRef.current?.sendCtrlAltDel()} className="inline-flex items-center gap-1 rounded px-2 py-1.5 text-xs text-gray-500 hover:bg-gray-200" title="发送 Ctrl+Alt+Del">
|
||||
<Send className="h-3.5 w-3.5" />
|
||||
Ctrl+Alt+Del
|
||||
</button>
|
||||
<button onClick={connect} className="rounded p-1.5 text-xs text-gray-500 hover:bg-gray-200" title="重新连接">
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button onClick={onClose} className="rounded p-1.5 text-gray-500 hover:bg-gray-200" title="关闭">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative min-h-0 flex-1 overflow-hidden bg-black">
|
||||
<div ref={screenRef} className="h-full w-full [&>div]:h-full [&>div]:w-full [&_canvas]:block" />
|
||||
{(status === 'connecting' || status === 'error' || (status === 'disconnected' && errorMsg)) && (
|
||||
<div className={`absolute inset-x-0 bottom-0 border-t px-4 py-2 text-sm ${status === 'error' ? 'border-red-900 bg-red-950 text-red-100' : 'border-gray-800 bg-gray-950 text-gray-200'}`}>
|
||||
{status === 'connecting' ? '正在连接 KVM VNC 控制台...' : (errorMsg || 'WebVNC 已断开')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -41,9 +41,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
if (savedToken) {
|
||||
const payload = decodeTokenPayload(savedToken)
|
||||
const nextUsername = payload?.username || payload?.sub_user || savedUsername || null
|
||||
const nextContainerIdentifiers = Array.isArray(payload?.container_uuids) && payload.container_uuids.length > 0
|
||||
? payload.container_uuids
|
||||
: Array.isArray(payload?.container_names) ? payload.container_names : []
|
||||
const nextContainerIdentifiers = Array.isArray(payload?.container_uuids) ? payload.container_uuids : []
|
||||
|
||||
setToken(savedToken)
|
||||
setUsername(nextUsername)
|
||||
@@ -123,7 +121,6 @@ export function useAuth() {
|
||||
type TokenPayload = {
|
||||
username?: string
|
||||
sub_user?: string
|
||||
container_names?: string[]
|
||||
container_uuids?: string[]
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { ReactNode, createContext, useContext, useEffect, useMemo, useState } from 'react'
|
||||
import { translateText } from '../utils/i18n'
|
||||
import { getLanguage, updateLanguage } from '../services/api'
|
||||
|
||||
export type Language = 'zh' | 'en'
|
||||
|
||||
interface LanguageContextValue {
|
||||
language: Language
|
||||
setLanguage: (language: Language) => void
|
||||
toggleLanguage: () => Promise<void>
|
||||
t: (value: string) => string
|
||||
}
|
||||
|
||||
const LanguageContext = createContext<LanguageContextValue | undefined>(undefined)
|
||||
function initialLanguage(): Language {
|
||||
return 'zh'
|
||||
}
|
||||
|
||||
export function LanguageProvider({ children }: { children: ReactNode }) {
|
||||
const [language, setLanguageState] = useState<Language>(initialLanguage)
|
||||
|
||||
const setLanguageLocal = (next: Language) => {
|
||||
setLanguageState(next)
|
||||
}
|
||||
|
||||
const setLanguage = (next: Language) => {
|
||||
setLanguageLocal(next)
|
||||
updateLanguage(next).catch(() => {})
|
||||
}
|
||||
|
||||
const value = useMemo<LanguageContextValue>(() => ({
|
||||
language,
|
||||
setLanguage,
|
||||
toggleLanguage: async () => {
|
||||
const next = language === 'zh' ? 'en' : 'zh'
|
||||
setLanguageLocal(next)
|
||||
try {
|
||||
const res = await updateLanguage(next)
|
||||
setLanguageLocal(res.data.data?.language || next)
|
||||
} catch {
|
||||
setLanguageLocal(language)
|
||||
}
|
||||
},
|
||||
t: (text: string) => language === 'en' ? translateText(text) : text,
|
||||
}), [language])
|
||||
|
||||
useEffect(() => {
|
||||
getLanguage()
|
||||
.then((res) => {
|
||||
const serverLanguage = res.data.data?.language
|
||||
if (serverLanguage === 'zh' || serverLanguage === 'en') {
|
||||
setLanguageLocal(serverLanguage)
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.lang = language === 'en' ? 'en' : 'zh-CN'
|
||||
document.documentElement.dataset.language = language
|
||||
}, [language])
|
||||
|
||||
return <LanguageContext.Provider value={value}>{children}</LanguageContext.Provider>
|
||||
}
|
||||
|
||||
export function useLanguage() {
|
||||
const context = useContext(LanguageContext)
|
||||
if (!context) {
|
||||
throw new Error('useLanguage must be used within LanguageProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { createContext, useContext, useEffect, useState, useCallback } from 'react'
|
||||
|
||||
type Theme = 'light' | 'dark'
|
||||
|
||||
interface ThemeContextType {
|
||||
theme: Theme
|
||||
toggleTheme: () => void
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextType>({ theme: 'light', toggleTheme: () => {} })
|
||||
|
||||
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
const [theme, setTheme] = useState<Theme>(() => {
|
||||
if (typeof window === 'undefined') return 'light'
|
||||
const stored = localStorage.getItem('clicd_theme') as Theme | null
|
||||
if (stored === 'dark' || stored === 'light') return stored
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement
|
||||
if (theme === 'dark') {
|
||||
root.classList.add('dark')
|
||||
} else {
|
||||
root.classList.remove('dark')
|
||||
}
|
||||
localStorage.setItem('clicd_theme', theme)
|
||||
}, [theme])
|
||||
|
||||
const toggleTheme = useCallback(() => {
|
||||
setTheme(prev => (prev === 'dark' ? 'light' : 'dark'))
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, toggleTheme }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
return useContext(ThemeContext)
|
||||
}
|
||||
+131
-3
@@ -14,19 +14,147 @@ body {
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #888;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #555;
|
||||
}
|
||||
|
||||
/* ============ DARK MODE OVERRIDES ============ */
|
||||
|
||||
.dark ::-webkit-scrollbar-track {
|
||||
background: #1f2937;
|
||||
}
|
||||
.dark ::-webkit-scrollbar-thumb {
|
||||
background: #4b5563;
|
||||
}
|
||||
.dark ::-webkit-scrollbar-thumb:hover {
|
||||
background: #6b7280;
|
||||
}
|
||||
|
||||
.dark body {
|
||||
background-color: #030712;
|
||||
color: #f9fafb;
|
||||
}
|
||||
|
||||
/* Background overrides */
|
||||
.dark .bg-white { background-color: #111827 !important; }
|
||||
.dark .bg-gray-50 { background-color: #030712 !important; }
|
||||
.dark .bg-gray-100 { background-color: #1f2937 !important; }
|
||||
.dark .bg-gray-200 { background-color: #374151 !important; }
|
||||
|
||||
/* Text overrides */
|
||||
.dark .text-black { color: #f9fafb !important; }
|
||||
.dark .text-gray-950 { color: #f9fafb !important; }
|
||||
.dark .text-gray-900 { color: #f3f4f6 !important; }
|
||||
.dark .text-gray-800 { color: #e5e7eb !important; }
|
||||
.dark .text-gray-700 { color: #d1d5db !important; }
|
||||
.dark .text-gray-600 { color: #9ca3af !important; }
|
||||
.dark .text-gray-500 { color: #9ca3af !important; }
|
||||
.dark .text-gray-400 { color: #6b7280 !important; }
|
||||
|
||||
/* Border overrides */
|
||||
.dark .border-gray-100 { border-color: #1f2937 !important; }
|
||||
.dark .border-gray-200 { border-color: #374151 !important; }
|
||||
.dark .border-gray-300 { border-color: #4b5563 !important; }
|
||||
|
||||
/* Divider overrides */
|
||||
.dark .divide-gray-50 > :not([hidden]) ~ :not([hidden]) { border-color: #1f2937 !important; }
|
||||
.dark .divide-gray-100 > :not([hidden]) ~ :not([hidden]) { border-color: #1f2937 !important; }
|
||||
|
||||
/* Hover background overrides */
|
||||
.dark .hover\:bg-gray-50:hover { background-color: #1f2937 !important; }
|
||||
.dark .hover\:bg-gray-100:hover { background-color: #1f2937 !important; }
|
||||
.dark .hover\:bg-gray-200:hover { background-color: #374151 !important; }
|
||||
|
||||
/* Hover text overrides */
|
||||
.dark .hover\:text-black:hover { color: #f9fafb !important; }
|
||||
.dark .hover\:text-gray-900:hover { color: #f3f4f6 !important; }
|
||||
|
||||
/* Shadow */
|
||||
.dark .shadow-sm { box-shadow: 0 1px 2px 0 rgba(0,0,0,0.3) !important; }
|
||||
.dark .shadow-md { box-shadow: 0 4px 6px -1px rgba(0,0,0,0.4) !important; }
|
||||
|
||||
/* bg-black buttons in dark mode -> light */
|
||||
.dark .bg-black { background-color: #f9fafb !important; }
|
||||
.dark .bg-black + span,
|
||||
.dark button.bg-black { color: #111827 !important; }
|
||||
.dark button.bg-black span { color: #111827 !important; }
|
||||
|
||||
/* Fix for CTA buttons (bg-black text-white) */
|
||||
.dark button.bg-black,
|
||||
.dark a.bg-black {
|
||||
background-color: #f9fafb !important;
|
||||
color: #111827 !important;
|
||||
}
|
||||
|
||||
/* Fix nested text-white inside bg-black in dark mode */
|
||||
.dark .bg-black .text-white,
|
||||
.dark .bg-black.text-white {
|
||||
color: #111827 !important;
|
||||
}
|
||||
|
||||
/* Invert sidebar active state */
|
||||
.dark button.bg-black.text-white,
|
||||
.dark button.bg-black > span {
|
||||
color: #111827 !important;
|
||||
}
|
||||
.dark button.bg-black svg {
|
||||
color: #111827 !important;
|
||||
}
|
||||
|
||||
/* Hover: bg-gray-800 in dark mode */
|
||||
.dark .hover\:bg-gray-800:hover { background-color: #e5e7eb !important; color: #111827 !important; }
|
||||
|
||||
/* Status badge backgrounds */
|
||||
.dark .bg-green-50 { background-color: #064e3b !important; }
|
||||
.dark .bg-red-50 { background-color: #450a0a !important; }
|
||||
.dark .bg-amber-50 { background-color: #451a03 !important; }
|
||||
.dark .bg-emerald-50 { background-color: #064e3b !important; }
|
||||
.dark .bg-amber-100 { background-color: #78350f !important; }
|
||||
|
||||
/* Status badge text */
|
||||
.dark .text-green-700 { color: #6ee7b7 !important; }
|
||||
.dark .text-red-600 { color: #fca5a5 !important; }
|
||||
.dark .text-red-700 { color: #fca5a5 !important; }
|
||||
.dark .text-amber-600 { color: #fcd34d !important; }
|
||||
.dark .text-amber-700 { color: #fcd34d !important; }
|
||||
.dark .text-emerald-700 { color: #6ee7b7 !important; }
|
||||
|
||||
/* Focus ring */
|
||||
.dark .focus\:ring-black:focus { --tw-ring-color: #f9fafb !important; }
|
||||
.dark .focus\:border-black:focus { border-color: #f9fafb !important; }
|
||||
|
||||
/* Accent */
|
||||
.dark .accent-black { accent-color: #f9fafb !important; }
|
||||
|
||||
/* Spinner */
|
||||
.dark .border-black { border-color: #f9fafb !important; }
|
||||
.dark .border-b-black { border-bottom-color: #f9fafb !important; }
|
||||
.dark .border-t-black { border-top-color: #f9fafb !important; }
|
||||
.dark .animate-spin.rounded-full { border-color: #f9fafb !important; border-bottom-color: transparent !important; }
|
||||
|
||||
/* Placeholder */
|
||||
.dark .placeholder-gray-400::placeholder { color: #6b7280 !important; }
|
||||
|
||||
/* Success/Error text standalone */
|
||||
.dark .text-green-600 { color: #6ee7b7 !important; }
|
||||
|
||||
/* Modal backdrop */
|
||||
.dark .bg-black\/50 { background-color: rgba(0,0,0,0.7) !important; }
|
||||
|
||||
/* Toggle / switch */
|
||||
.dark .bg-gray-300 { background-color: #4b5563 !important; }
|
||||
.dark .peer-checked\:bg-black:checked ~ * { background-color: #f9fafb !important; }
|
||||
.dark .peer-checked\:bg-black:checked + *,
|
||||
.dark input.peer:checked + .peer-checked\:bg-black { background-color: #f9fafb !important; }
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user