mirror of
https://github.com/MengMengCode/CLICD.git
synced 2026-08-05 05:36:07 +08:00
Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
Submodule .claude/worktrees/agent-ae3871aebda20eb86 deleted from 422e48b524
@@ -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,6 +58,7 @@ backend/tmp/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.claude/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
|
||||
@@ -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.
|
||||
@@ -4,20 +4,57 @@
|
||||
|
||||
<h1 align="center">CLICD</h1>
|
||||
|
||||
<p align="center">
|
||||
<img alt="Release" src="https://img.shields.io/github/v/release/MengMengCode/CLICD?style=flat-square">
|
||||
<img alt="Stars" src="https://img.shields.io/github/stars/MengMengCode/CLICD?style=flat-square">
|
||||
<img alt="Forks" src="https://img.shields.io/github/forks/MengMengCode/CLICD?style=flat-square">
|
||||
<img alt="Downloads" src="https://img.shields.io/github/downloads/MengMengCode/CLICD/total?style=flat-square">
|
||||
<img alt="Last Commit" src="https://img.shields.io/github/last-commit/MengMengCode/CLICD?style=flat-square">
|
||||
<img alt="License" src="https://img.shields.io/github/license/MengMengCode/CLICD.svg?style=flat-square">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img alt="Go" src="https://img.shields.io/badge/Go-1.22-00ADD8?style=flat-square&logo=go&logoColor=white">
|
||||
<img alt="React" src="https://img.shields.io/badge/React-18-61DAFB?style=flat-square&logo=react&logoColor=111111">
|
||||
<img alt="TypeScript" src="https://img.shields.io/badge/TypeScript-5-3178C6?style=flat-square&logo=typescript&logoColor=white">
|
||||
<img alt="Vite" src="https://img.shields.io/badge/Vite-5-646CFF?style=flat-square&logo=vite&logoColor=white">
|
||||
<img alt="Tailwind CSS" src="https://img.shields.io/badge/Tailwind_CSS-3-06B6D4?style=flat-square&logo=tailwindcss&logoColor=white">
|
||||
<img alt="LXC" src="https://img.shields.io/badge/LXC-container-111111?style=flat-square">
|
||||
<img alt="KVM" src="https://img.shields.io/badge/KVM-virtualization-EE0000?style=flat-square&logo=linux&logoColor=white">
|
||||
<img alt="LXC" src="https://img.shields.io/badge/LXC-Supported-111111?style=flat-square">
|
||||
<img alt="KVM" src="https://img.shields.io/badge/KVM-Supported-EE0000?style=flat-square">
|
||||
</p>
|
||||
|
||||
<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. 支持设置 NAT4 端口数量、NAT 端口映射和协议限制,并支持分配公网 IPv6。IPv6 分配要求宿主机本身拥有可路由的 IPv6 地址段。
|
||||
@@ -28,22 +65,22 @@ CLICD 是一个面向 LXC/KVM 的轻量虚拟化管理面板,提供 Web 控制
|
||||
8. 支持 API 接入,可以通过 API 完成容器、任务、镜像、端口、流量、安全告警等功能的自动化控制。
|
||||
9. 支持仅使用 CLI 管理。需要关闭 Web 控制台时,可以停止并禁用 systemd 服务,然后使用 `clicd cli --no-web` 进入命令行模式。
|
||||
|
||||
## 技术栈
|
||||
## Technology Stack / 技术栈
|
||||
|
||||
- Backend: Go, net/http, LXC, KVM/libvirt, cgroup v2, iptables, conntrack
|
||||
- Frontend: React, TypeScript, Vite, Tailwind CSS, lucide-react, xterm.js
|
||||
- Runtime: Linux, systemd, LXC, KVM/QEMU
|
||||
- Build: GitHub Actions, Node.js 20, Go 1.22
|
||||
|
||||
## 安装
|
||||
## Installation / 安装
|
||||
|
||||
一键安装:
|
||||
One-click Install / 一键安装:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/MengMengCode/CLICD/main/install.sh | sudo sh
|
||||
```
|
||||
|
||||
一键卸载:
|
||||
One-click Uninstall / 一键卸载:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/MengMengCode/CLICD/main/install.sh | sudo sh -s -- uninstall
|
||||
@@ -83,4 +120,4 @@ This open-source software is intended solely for educational purposes, specifica
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=MengMengCode/CLICD&type=date&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=MengMengCode/CLICD&type=date&legend=top-left" />
|
||||
</picture>
|
||||
</a>
|
||||
</a>
|
||||
|
||||
+272
-106
@@ -8,7 +8,6 @@ import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -18,66 +17,100 @@ import (
|
||||
)
|
||||
|
||||
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)
|
||||
@@ -94,31 +127,109 @@ func createApiKey(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
now := time.Now().Format("2006-01-02 15:04:05")
|
||||
scopes := normalizeRequestedScopes(req.Scopes, defaultApiKeyScopes)
|
||||
key := config.ApiKeyConfig{
|
||||
ID: generateShortID(),
|
||||
Name: req.Name,
|
||||
KeyHash: keyHash,
|
||||
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)
|
||||
@@ -203,13 +314,21 @@ func matchApiKey(rawKey string) (idx int, needsRehash bool) {
|
||||
|
||||
// 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 false
|
||||
return nil, false
|
||||
}
|
||||
k := config.AppConfig.ApiKeys[idx]
|
||||
if k.IPWhitelist != "" && !isIPAllowed(clientIP, k.IPWhitelist) {
|
||||
return 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 {
|
||||
@@ -217,7 +336,38 @@ func validateApiKey(rawKey, clientIP string) bool {
|
||||
config.SaveConfig()
|
||||
}
|
||||
}
|
||||
return true
|
||||
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 {
|
||||
@@ -232,23 +382,16 @@ func apiKeyFromRequest(r *http.Request) string {
|
||||
}
|
||||
|
||||
func isValidApiKeyRequest(r *http.Request) bool {
|
||||
apiKey := apiKeyFromRequest(r)
|
||||
if apiKey == "" {
|
||||
return false
|
||||
}
|
||||
if !validateApiKey(apiKey, clientIP(r)) {
|
||||
return false
|
||||
}
|
||||
updateApiKeyLastUsed(apiKey)
|
||||
return true
|
||||
_, ok := validateApiKeyRequest(r)
|
||||
return ok
|
||||
}
|
||||
|
||||
// isIPAllowed checks if clientIP matches any entry in the whitelist
|
||||
func isIPAllowed(clientIP, whitelist string) bool {
|
||||
clientIP = strings.TrimSpace(clientIP)
|
||||
// Strip port if present
|
||||
if idx := strings.LastIndex(clientIP, ":"); idx > strings.LastIndex(clientIP, "]") {
|
||||
clientIP = clientIP[:idx]
|
||||
clientIP = normalizeIPString(clientIP)
|
||||
client := net.ParseIP(clientIP)
|
||||
if client == nil {
|
||||
return false
|
||||
}
|
||||
for _, entry := range strings.Split(whitelist, "\n") {
|
||||
entry = strings.TrimSpace(entry)
|
||||
@@ -256,74 +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.
|
||||
func updateApiKeyLastUsed(rawKey string) {
|
||||
idx, _ := matchApiKey(rawKey)
|
||||
if idx < 0 {
|
||||
key, ok := validateApiKeyDetails(rawKey, "")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
config.AppConfig.ApiKeys[idx].LastUsed = time.Now().Format("2006-01-02 15:04:05")
|
||||
updateApiKeyLastUsedForKey(key, "")
|
||||
}
|
||||
|
||||
func updateApiKeyLastUsedForKey(key *config.ApiKeyConfig, ip string) {
|
||||
key.LastUsed = time.Now().Format("2006-01-02 15:04:05")
|
||||
if ip != "" {
|
||||
key.LastUsedIP = ip
|
||||
}
|
||||
config.SaveConfig()
|
||||
}
|
||||
|
||||
// ApiKeyMiddleware authenticates requests via X-API-Key header or Authorization bearer.
|
||||
func ApiKeyMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
apiKey := apiKeyFromRequest(r)
|
||||
if apiKey == "" || !validateApiKey(apiKey, clientIP(r)) {
|
||||
key, ok := validateApiKeyRequest(r)
|
||||
if !ok {
|
||||
jsonResponse(w, http.StatusUnauthorized, APIResponse{Success: false, Message: "Invalid API key or IP not in whitelist"})
|
||||
return
|
||||
}
|
||||
|
||||
updateApiKeyLastUsed(apiKey)
|
||||
next(w, r)
|
||||
next(w, withAuthContext(r, authContextFromAPIKey(key)))
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeApiKeyScopes(scopes []string) []string {
|
||||
return normalizeRequestedScopes(scopes, []string{"*"})
|
||||
}
|
||||
|
||||
func normalizeRequestedScopes(scopes []string, fallback []string) []string {
|
||||
result := normalizeStringSlice(scopes)
|
||||
if len(result) == 0 {
|
||||
return append([]string(nil), fallback...)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func normalizeStringSlice(values []string) []string {
|
||||
seen := map[string]bool{}
|
||||
result := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" || seen[value] {
|
||||
continue
|
||||
}
|
||||
seen[value] = true
|
||||
result = append(result, value)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func validApiKeyTime(value string) bool {
|
||||
_, err := time.ParseInLocation("2006-01-02 15:04:05", value, time.Local)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func apiKeyExpired(value string) bool {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return false
|
||||
}
|
||||
expiresAt, err := time.ParseInLocation("2006-01-02 15:04:05", value, time.Local)
|
||||
return err == nil && !time.Now().Before(expiresAt)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -75,8 +202,10 @@ func claimsFromToken(tokenString string) (jwt.MapClaims, bool) {
|
||||
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.
|
||||
@@ -86,6 +215,9 @@ func claimsFromToken(tokenString string) (jwt.MapClaims, bool) {
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundSubUser {
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
return claims, ok
|
||||
@@ -96,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
|
||||
@@ -210,19 +345,45 @@ func HandleCheckAuth(w http.ResponseWriter, r *http.Request) {
|
||||
func AuthMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
tokenString := tokenFromRequest(r)
|
||||
if !isValidToken(tokenString) && !isValidApiKeyRequest(r) {
|
||||
jsonResponse(w, http.StatusUnauthorized, APIResponse{Success: false, Message: "Authentication required"})
|
||||
if claims, ok := claimsFromToken(tokenString); ok {
|
||||
if subUser, _ := claims["sub_user"].(string); subUser != "" {
|
||||
auth := AuthContext{Type: authTypeSubUser, Username: subUser, Actor: "user:" + subUser}
|
||||
if values, ok := claims["container_uuids"].([]interface{}); ok {
|
||||
for _, value := range values {
|
||||
if uuid, ok := value.(string); ok {
|
||||
auth.ContainerUUIDs = append(auth.ContainerUUIDs, uuid)
|
||||
}
|
||||
}
|
||||
}
|
||||
next(w, withAuthContext(r, auth))
|
||||
return
|
||||
}
|
||||
username, _ := claims["username"].(string)
|
||||
if username == "" {
|
||||
username = config.AppConfig.AdminUser
|
||||
}
|
||||
next(w, withAuthContext(r, AuthContext{Type: authTypeAdmin, Username: username, Actor: username}))
|
||||
return
|
||||
}
|
||||
|
||||
next(w, r)
|
||||
if key, ok := validateApiKeyRequest(r); ok {
|
||||
next(w, withAuthContext(r, authContextFromAPIKey(key)))
|
||||
return
|
||||
}
|
||||
|
||||
jsonResponse(w, http.StatusUnauthorized, APIResponse{Success: false, Message: "Authentication required"})
|
||||
}
|
||||
}
|
||||
|
||||
// AdminMiddleware requires a valid administrator token and rejects sub-user tokens.
|
||||
func AdminMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
||||
return AuthMiddleware(func(w http.ResponseWriter, r *http.Request) {
|
||||
if isSubUserRequest(r) {
|
||||
ctx, _ := authContextFromRequest(r)
|
||||
if ctx.Type == authTypeSubUser {
|
||||
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Administrator permission required"})
|
||||
return
|
||||
}
|
||||
if ctx.Type == authTypeAPIKey && !scopeAllowed(ctx.Scopes, "admin:access") {
|
||||
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Administrator permission required"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
//go:build !linux
|
||||
|
||||
package api
|
||||
|
||||
func getRootDiskInfo() (DiskInfo, bool) {
|
||||
return DiskInfo{}, false
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -20,17 +20,41 @@ 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"})
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
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/containers/")
|
||||
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
|
||||
@@ -50,6 +74,10 @@ func HandleSingleContainer(w http.ResponseWriter, r *http.Request) {
|
||||
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/")
|
||||
@@ -61,45 +89,105 @@ func HandleSingleContainer(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
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"})
|
||||
@@ -348,6 +436,9 @@ func HandleTemplates(w http.ResponseWriter, r *http.Request) {
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||
return
|
||||
}
|
||||
if !requireScope(w, r, "image:read") {
|
||||
return
|
||||
}
|
||||
if isSubUserRequest(r) {
|
||||
HandleEnabledImages(w, r)
|
||||
return
|
||||
@@ -362,7 +453,11 @@ func HandleDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||
return
|
||||
}
|
||||
if !requireScope(w, r, "dashboard:read") {
|
||||
return
|
||||
}
|
||||
containers, _ := listByRuntime()
|
||||
containers = filterContainersForRequest(r, containers)
|
||||
running := 0
|
||||
stopped := 0
|
||||
for _, c := range containers {
|
||||
@@ -386,6 +481,9 @@ func HandleHostInfo(w http.ResponseWriter, r *http.Request) {
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||
return
|
||||
}
|
||||
if !requireScope(w, r, "host:read") {
|
||||
return
|
||||
}
|
||||
info := getHostInfo()
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: info})
|
||||
}
|
||||
|
||||
+1301
-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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -221,6 +221,9 @@ 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()
|
||||
@@ -287,6 +290,9 @@ func HandleImageDownload(w http.ResponseWriter, r *http.Request) {
|
||||
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"`
|
||||
@@ -397,6 +403,9 @@ func HandleImageCancel(w http.ResponseWriter, r *http.Request) {
|
||||
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"`
|
||||
}
|
||||
@@ -434,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"`
|
||||
@@ -484,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"`
|
||||
@@ -510,6 +525,9 @@ 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
|
||||
}
|
||||
|
||||
runtime := runtimeFromRequest(r.URL.Query().Get("type"))
|
||||
enabledSet := getEnabledImageSet()
|
||||
|
||||
@@ -7,6 +7,9 @@ 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})
|
||||
}
|
||||
|
||||
@@ -50,6 +50,9 @@ func HandleRouting(w http.ResponseWriter, r *http.Request) {
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||
return
|
||||
}
|
||||
if !requireScope(w, r, "routing:read") {
|
||||
return
|
||||
}
|
||||
|
||||
nat4Mappings := make([]nat4Route, 0)
|
||||
usedPorts := map[int]bool{}
|
||||
|
||||
@@ -654,18 +654,27 @@ func HandleSecurityAlerts(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
|
||||
}
|
||||
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: mergedSecurityAlerts()})
|
||||
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"`
|
||||
}
|
||||
@@ -678,6 +687,7 @@ func HandleSecuritySettings(w http.ResponseWriter, r *http.Request) {
|
||||
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,
|
||||
}})
|
||||
@@ -692,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"`
|
||||
@@ -706,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"})
|
||||
@@ -717,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 == "" {
|
||||
@@ -729,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)})
|
||||
}
|
||||
@@ -781,12 +805,15 @@ 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
|
||||
}
|
||||
|
||||
critical := 0
|
||||
high := 0
|
||||
medium := 0
|
||||
low := 0
|
||||
alerts := mergedSecurityAlerts()
|
||||
alerts := filterSecurityAlertsForRequest(r, mergedSecurityAlerts())
|
||||
for _, a := range alerts {
|
||||
switch a.Severity {
|
||||
case "critical":
|
||||
@@ -812,6 +839,20 @@ 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()
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -16,7 +16,11 @@ func HandleSnapshots(w http.ResponseWriter, r *http.Request) {
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||
return
|
||||
}
|
||||
if !requireScope(w, r, "snapshot:read") {
|
||||
return
|
||||
}
|
||||
snapshots := append([]config.Snapshot(nil), config.AppConfig.Snapshots...)
|
||||
snapshots = filterSnapshotsForRequest(r, snapshots)
|
||||
sortSnapshotsNewestFirst(snapshots)
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: snapshots})
|
||||
}
|
||||
@@ -24,17 +28,35 @@ func HandleSnapshots(w http.ResponseWriter, r *http.Request) {
|
||||
func handleContainerSnapshots(w http.ResponseWriter, r *http.Request, containerID int, action string) {
|
||||
switch {
|
||||
case action == "snapshots" && r.Method == http.MethodGet:
|
||||
if !requireScope(w, r, "snapshot:read") {
|
||||
return
|
||||
}
|
||||
listContainerSnapshots(w, r, containerID)
|
||||
case action == "snapshots" && r.Method == http.MethodPost:
|
||||
if !requireScope(w, r, "snapshot:create") {
|
||||
return
|
||||
}
|
||||
createContainerSnapshot(w, r, containerID)
|
||||
case action == "snapshots/schedule" && r.Method == http.MethodPost:
|
||||
if !requireScope(w, r, "snapshot:schedule") {
|
||||
return
|
||||
}
|
||||
updateSnapshotSchedule(w, r, containerID)
|
||||
case action == "snapshots/quota" && r.Method == http.MethodPut:
|
||||
if !requireScope(w, r, "snapshot:schedule") {
|
||||
return
|
||||
}
|
||||
updateSnapshotQuota(w, r, containerID)
|
||||
case strings.HasPrefix(action, "snapshots/") && strings.HasSuffix(action, "/restore") && r.Method == http.MethodPost:
|
||||
if !requireScope(w, r, "snapshot:restore") {
|
||||
return
|
||||
}
|
||||
snapshotID := strings.TrimSuffix(strings.TrimPrefix(action, "snapshots/"), "/restore")
|
||||
restoreContainerSnapshot(w, r, containerID, snapshotID)
|
||||
case strings.HasPrefix(action, "snapshots/") && r.Method == http.MethodDelete:
|
||||
if !requireScope(w, r, "snapshot:delete") {
|
||||
return
|
||||
}
|
||||
snapshotID := strings.TrimPrefix(action, "snapshots/")
|
||||
deleteContainerSnapshot(w, r, containerID, snapshotID)
|
||||
default:
|
||||
@@ -186,15 +208,7 @@ func restoreContainerSnapshot(w http.ResponseWriter, r *http.Request, containerI
|
||||
}
|
||||
|
||||
func requestUser(r *http.Request) string {
|
||||
if claims, ok := claimsFromRequest(r); ok {
|
||||
if subUser, _ := claims["sub_user"].(string); subUser != "" {
|
||||
return "user:" + subUser
|
||||
}
|
||||
if username, _ := claims["username"].(string); username != "" {
|
||||
return username
|
||||
}
|
||||
}
|
||||
return "admin"
|
||||
return requestActor(r)
|
||||
}
|
||||
|
||||
func sortSnapshotsNewestFirst(snapshots []config.Snapshot) {
|
||||
@@ -204,3 +218,17 @@ func sortSnapshotsNewestFirst(snapshots []config.Snapshot) {
|
||||
return tj.Before(ti)
|
||||
})
|
||||
}
|
||||
|
||||
func filterSnapshotsForRequest(r *http.Request, snapshots []config.Snapshot) []config.Snapshot {
|
||||
allowed, restricted := requestAllowedContainers(r)
|
||||
if !restricted {
|
||||
return snapshots
|
||||
}
|
||||
filtered := make([]config.Snapshot, 0, len(snapshots))
|
||||
for _, snapshot := range snapshots {
|
||||
if c := config.FindContainer(snapshot.ContainerID); c != nil && isContainerAllowed(allowed, c) {
|
||||
filtered = append(filtered, snapshot)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
@@ -42,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"`
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,9 @@ func HandleSubUserCreate(w http.ResponseWriter, r *http.Request) {
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||
return
|
||||
}
|
||||
if !requireScope(w, r, "subuser:create") {
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
ContainerName string `json:"container_name"`
|
||||
@@ -281,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)
|
||||
@@ -303,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 {
|
||||
@@ -327,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
|
||||
@@ -341,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])
|
||||
@@ -373,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))
|
||||
@@ -387,33 +428,47 @@ func filterContainersForRequest(r *http.Request, containers []config.Container)
|
||||
}
|
||||
|
||||
func filterTasksForRequest(r *http.Request, tasks []*Task) []*Task {
|
||||
allowed, isSubUser := subUserAllowedContainers(r)
|
||||
if !isSubUser {
|
||||
return tasks
|
||||
}
|
||||
filtered := make([]*Task, 0, len(tasks))
|
||||
for _, task := range tasks {
|
||||
if c := config.FindContainer(task.ContainerID); c != nil && isContainerAllowed(allowed, c) {
|
||||
if isTaskAllowedForRequest(r, task) {
|
||||
filtered = append(filtered, task)
|
||||
continue
|
||||
}
|
||||
if task.ContainerName != "" {
|
||||
if c := config.FindContainerByName(task.ContainerName); c != nil && isContainerAllowed(allowed, c) {
|
||||
filtered = append(filtered, task)
|
||||
continue
|
||||
}
|
||||
}
|
||||
if task.Config.Name != "" {
|
||||
if c := config.FindContainerByName(task.Config.Name); c != nil && isContainerAllowed(allowed, c) {
|
||||
filtered = append(filtered, task)
|
||||
}
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func isTaskAllowedForRequest(r *http.Request, task *Task) bool {
|
||||
allowed, restricted := requestAllowedContainers(r)
|
||||
if !restricted {
|
||||
return true
|
||||
}
|
||||
if task == nil {
|
||||
return false
|
||||
}
|
||||
if c := config.FindContainer(task.ContainerID); c != nil && isContainerAllowed(allowed, c) {
|
||||
return true
|
||||
}
|
||||
if task.ContainerName != "" {
|
||||
if c := config.FindContainerByName(task.ContainerName); c != nil && isContainerAllowed(allowed, c) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if task.Config.Name != "" {
|
||||
if c := config.FindContainerByName(task.Config.Name); c != nil && isContainerAllowed(allowed, c) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isContainerAllowed(allowed subUserAccess, c *config.Container) bool {
|
||||
return c != nil && c.UUID != "" && allowed.uuids[c.UUID]
|
||||
if c == nil {
|
||||
return false
|
||||
}
|
||||
if c.UUID != "" && allowed.uuids[c.UUID] {
|
||||
return true
|
||||
}
|
||||
return c.Name != "" && allowed.names[c.Name]
|
||||
}
|
||||
|
||||
func isSubUserBlockedAction(action string, method string) bool {
|
||||
@@ -536,6 +591,9 @@ func HandleSubUserList(w http.ResponseWriter, r *http.Request) {
|
||||
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 {
|
||||
@@ -585,7 +643,8 @@ func HandleSubUserList(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// HandleSubUserAction handles actions on a specific sub-user
|
||||
func HandleSubUserAction(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api/sub-users/")
|
||||
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 := ""
|
||||
@@ -608,6 +667,9 @@ func HandleSubUserAction(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
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)
|
||||
@@ -625,11 +687,17 @@ func HandleSubUserAction(w http.ResponseWriter, r *http.Request) {
|
||||
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})
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -122,9 +122,13 @@ func (q *TaskQueue) EnqueueBatchWithAudit(taskType TaskType, ids []int, template
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -147,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
|
||||
@@ -161,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)
|
||||
@@ -424,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)
|
||||
@@ -456,13 +465,8 @@ 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")
|
||||
|
||||
@@ -517,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"`
|
||||
}
|
||||
@@ -576,7 +587,7 @@ func HandleBatchCreate(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
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})
|
||||
}
|
||||
|
||||
@@ -586,6 +597,10 @@ func HandleBatchAction(w http.ResponseWriter, r *http.Request) {
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||
return
|
||||
}
|
||||
if !hasAnyScope(r, "container:power", "container:delete", "container:reinstall") {
|
||||
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Insufficient API key scope"})
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Action string `json:"action"`
|
||||
Containers []int `json:"containers"`
|
||||
@@ -597,21 +612,47 @@ func HandleBatchAction(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
var taskType TaskType
|
||||
var requiredScope string
|
||||
switch req.Action {
|
||||
case "start":
|
||||
taskType = TaskStart
|
||||
requiredScope = "container:power"
|
||||
case "stop":
|
||||
taskType = TaskStop
|
||||
requiredScope = "container:power"
|
||||
case "restart":
|
||||
taskType = TaskRestart
|
||||
requiredScope = "container:power"
|
||||
case "delete":
|
||||
taskType = TaskDelete
|
||||
requiredScope = "container:delete"
|
||||
case "reinstall":
|
||||
if req.TemplateID == "" {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "template_id required"})
|
||||
return
|
||||
}
|
||||
if !isTemplateEnabledAndDownloaded(req.TemplateID) {
|
||||
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Template is not enabled or downloaded"})
|
||||
return
|
||||
}
|
||||
taskType = TaskReinstall
|
||||
requiredScope = "container:reinstall"
|
||||
default:
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Unknown action"})
|
||||
return
|
||||
}
|
||||
if !requireScope(w, r, requiredScope) {
|
||||
return
|
||||
}
|
||||
for _, id := range req.Containers {
|
||||
c := config.FindContainer(id)
|
||||
if c == nil || !isContainerAllowedForRequest(r, c.UUID) {
|
||||
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Access denied to one or more containers"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
ids := globalQueue.EnqueueBatch(taskType, req.Containers, req.TemplateID)
|
||||
ids := globalQueue.EnqueueBatchWithAudit(taskType, req.Containers, req.TemplateID, requestActor(r), clientIP(r), r.UserAgent())
|
||||
jsonResponse(w, http.StatusAccepted, APIResponse{Success: true, Data: ids})
|
||||
}
|
||||
|
||||
@@ -621,13 +662,22 @@ func HandleTaskDelete(w http.ResponseWriter, r *http.Request) {
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||
return
|
||||
}
|
||||
// URL: /api/tasks/{id}
|
||||
taskID := strings.TrimPrefix(r.URL.Path, "/api/tasks/")
|
||||
if !requireScope(w, r, "task:delete") {
|
||||
return
|
||||
}
|
||||
// URL: /api/tasks/{id} or /api/v1/tasks/{id}
|
||||
taskID := strings.TrimPrefix(r.URL.Path, "/api/v1/tasks/")
|
||||
taskID = strings.TrimPrefix(taskID, "/api/tasks/")
|
||||
if taskID == "" {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Task ID required"})
|
||||
return
|
||||
}
|
||||
globalQueue.mu.Lock()
|
||||
if task := globalQueue.tasks[taskID]; task != nil && !isTaskAllowedForRequest(r, task) {
|
||||
globalQueue.mu.Unlock()
|
||||
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Access denied to this task"})
|
||||
return
|
||||
}
|
||||
delete(globalQueue.tasks, taskID)
|
||||
// Also remove from both queues if pending
|
||||
newCreate := make([]*Task, 0, len(globalQueue.createQueue))
|
||||
@@ -655,6 +705,9 @@ func HandleTasks(w http.ResponseWriter, r *http.Request) {
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||
return
|
||||
}
|
||||
if !requireScope(w, r, "task:read") {
|
||||
return
|
||||
}
|
||||
tasks := globalQueue.GetTasks()
|
||||
tasks = filterTasksForRequest(r, tasks)
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: tasks})
|
||||
@@ -691,6 +744,8 @@ func RestoreTasks() {
|
||||
TemplateID: st.TemplateID,
|
||||
Config: cfg,
|
||||
User: st.User,
|
||||
IP: st.IP,
|
||||
UserAgent: st.UserAgent,
|
||||
}
|
||||
if st.Status == "pending" || st.Status == "running" {
|
||||
// Reset running tasks back to pending so they get retried
|
||||
|
||||
@@ -18,7 +18,10 @@ import (
|
||||
type webVNCTicket struct {
|
||||
ContainerName string
|
||||
ContainerUUID string
|
||||
Username string
|
||||
SubUser bool
|
||||
ClientIP string
|
||||
UserAgent string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
@@ -33,6 +36,9 @@ func HandleVNCTicket(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if !requireScope(w, r, "terminal:vnc") {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
ContainerName string `json:"container_name"`
|
||||
}
|
||||
@@ -58,13 +64,17 @@ func HandleVNCTicket(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
username, isSubUser := vncRequesterIdentity(r)
|
||||
ticket := randomHex(32)
|
||||
webVNCTickets.Lock()
|
||||
cleanupExpiredWebVNCTicketsLocked(time.Now())
|
||||
webVNCTickets.items[ticket] = webVNCTicket{
|
||||
ContainerName: c.Name,
|
||||
ContainerUUID: c.UUID,
|
||||
SubUser: isSubUserRequest(r),
|
||||
Username: username,
|
||||
SubUser: isSubUser,
|
||||
ClientIP: clientIP(r),
|
||||
UserAgent: r.UserAgent(),
|
||||
ExpiresAt: time.Now().Add(60 * time.Second),
|
||||
}
|
||||
webVNCTickets.Unlock()
|
||||
@@ -89,7 +99,7 @@ func HandleVNCProxy(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
item, ok := consumeWebVNCTicket(ticket, containerName)
|
||||
item, ok := consumeWebVNCTicket(ticket, containerName, r)
|
||||
if !ok {
|
||||
http.Error(w, "invalid or expired ticket", http.StatusUnauthorized)
|
||||
return
|
||||
@@ -137,7 +147,7 @@ func HandleVNCProxy(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
defer ws.Close()
|
||||
|
||||
log.Printf("WebVNC connected for container %s -> 127.0.0.1:%d", containerName, vncPort)
|
||||
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
|
||||
@@ -147,7 +157,31 @@ func HandleVNCProxy(w http.ResponseWriter, r *http.Request) {
|
||||
reason := <-done
|
||||
_ = vncConn.Close()
|
||||
_ = ws.Close()
|
||||
log.Printf("WebVNC disconnected for container %s: %s", containerName, reason)
|
||||
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 {
|
||||
@@ -175,7 +209,7 @@ func webVNCResponseProtocol(r *http.Request) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func consumeWebVNCTicket(ticket, containerName string) (webVNCTicket, bool) {
|
||||
func consumeWebVNCTicket(ticket, containerName string, r *http.Request) (webVNCTicket, bool) {
|
||||
now := time.Now()
|
||||
webVNCTickets.Lock()
|
||||
defer webVNCTickets.Unlock()
|
||||
@@ -185,7 +219,10 @@ func consumeWebVNCTicket(ticket, containerName string) (webVNCTicket, bool) {
|
||||
return webVNCTicket{}, false
|
||||
}
|
||||
delete(webVNCTickets.items, ticket)
|
||||
return item, item.ContainerName == containerName && now.Before(item.ExpiresAt)
|
||||
return item, item.ContainerName == containerName &&
|
||||
item.ClientIP == clientIP(r) &&
|
||||
item.UserAgent == r.UserAgent() &&
|
||||
now.Before(item.ExpiresAt)
|
||||
}
|
||||
|
||||
func cleanupExpiredWebVNCTicketsLocked(now time.Time) {
|
||||
|
||||
+573
-116
File diff suppressed because it is too large
Load Diff
@@ -33,6 +33,8 @@ type SavedTask struct {
|
||||
TemplateID string `json:"template_id,omitempty"`
|
||||
Config string `json:"config,omitempty"`
|
||||
User string `json:"user,omitempty"`
|
||||
IP string `json:"ip,omitempty"`
|
||||
UserAgent string `json:"user_agent,omitempty"`
|
||||
}
|
||||
|
||||
// SavedLoginLog for persisting login logs
|
||||
@@ -152,13 +154,18 @@ func (c *Container) VirshName() string {
|
||||
|
||||
// SubUser represents a sub-user with access to specific containers
|
||||
type ApiKeyConfig struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
KeyHash string `json:"key_hash"`
|
||||
Prefix string `json:"prefix"`
|
||||
IPWhitelist string `json:"ip_whitelist"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
LastUsed string `json:"last_used"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
KeyHash string `json:"key_hash"`
|
||||
Prefix string `json:"prefix"`
|
||||
IPWhitelist string `json:"ip_whitelist"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
LastUsed string `json:"last_used"`
|
||||
Scopes []string `json:"scopes,omitempty"`
|
||||
ExpiresAt string `json:"expires_at,omitempty"`
|
||||
Disabled bool `json:"disabled,omitempty"`
|
||||
ContainerUUIDs []string `json:"container_uuids,omitempty"`
|
||||
LastUsedIP string `json:"last_used_ip,omitempty"`
|
||||
}
|
||||
|
||||
// DeleteApiKey removes an API key by ID
|
||||
@@ -198,26 +205,47 @@ type Snapshot struct {
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
}
|
||||
|
||||
const (
|
||||
SSLModeDisabled = "disabled"
|
||||
SSLModeLetsEncrypt = "letsencrypt"
|
||||
SSLModeSelfSigned = "self_signed"
|
||||
SSLModeUploaded = "uploaded"
|
||||
)
|
||||
|
||||
type SSLConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Mode string `json:"mode"`
|
||||
Target string `json:"target"`
|
||||
Email string `json:"email,omitempty"`
|
||||
CertPath string `json:"cert_path,omitempty"`
|
||||
KeyPath string `json:"key_path,omitempty"`
|
||||
LastIssuedAt string `json:"last_issued_at,omitempty"`
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
}
|
||||
|
||||
// ClicdConfig is the main configuration structure
|
||||
type ClicdConfig struct {
|
||||
AdminUser string `json:"admin_user"`
|
||||
AdminPassHash string `json:"admin_pass_hash"`
|
||||
JWTSecret string `json:"jwt_secret"`
|
||||
Port int `json:"port"`
|
||||
DataDir string `json:"data_dir"`
|
||||
Containers []Container `json:"containers"`
|
||||
NextContainerID int `json:"next_container_id"`
|
||||
NextVNCPort int `json:"next_vnc_port"`
|
||||
NextSSHPort int `json:"next_ssh_port"`
|
||||
SetupComplete bool `json:"setup_complete"`
|
||||
SubUsers []SubUser `json:"sub_users"`
|
||||
ApiKeys []ApiKeyConfig `json:"api_keys"`
|
||||
AuditLogs []AuditLog `json:"audit_logs"`
|
||||
Tasks []SavedTask `json:"tasks"`
|
||||
LoginLogs []SavedLoginLog `json:"login_logs"`
|
||||
EnabledImages []string `json:"enabled_images"`
|
||||
Snapshots []Snapshot `json:"snapshots"`
|
||||
SecurityAutoShutdown bool `json:"security_auto_shutdown"`
|
||||
AdminUser string `json:"admin_user"`
|
||||
AdminPassHash string `json:"admin_pass_hash"`
|
||||
JWTSecret string `json:"jwt_secret"`
|
||||
Port int `json:"port"`
|
||||
DataDir string `json:"data_dir"`
|
||||
Containers []Container `json:"containers"`
|
||||
NextContainerID int `json:"next_container_id"`
|
||||
NextVNCPort int `json:"next_vnc_port"`
|
||||
NextSSHPort int `json:"next_ssh_port"`
|
||||
SetupComplete bool `json:"setup_complete"`
|
||||
SubUsers []SubUser `json:"sub_users"`
|
||||
ApiKeys []ApiKeyConfig `json:"api_keys"`
|
||||
AuditLogs []AuditLog `json:"audit_logs"`
|
||||
Tasks []SavedTask `json:"tasks"`
|
||||
LoginLogs []SavedLoginLog `json:"login_logs"`
|
||||
EnabledImages []string `json:"enabled_images"`
|
||||
Snapshots []Snapshot `json:"snapshots"`
|
||||
SecurityAutoShutdown bool `json:"security_auto_shutdown"`
|
||||
Language string `json:"language"`
|
||||
SSL SSLConfig `json:"ssl"`
|
||||
SSLCertificates map[string]SSLConfig `json:"ssl_certificates"`
|
||||
}
|
||||
|
||||
var configPath string
|
||||
@@ -295,8 +323,11 @@ func InitConfig() (*ClicdConfig, error) {
|
||||
}
|
||||
if ok {
|
||||
AppConfig = cfg
|
||||
normalizeConfigDefaults(dataDir)
|
||||
changed := normalizeConfigDefaults(dataDir)
|
||||
if migrateLoadedConfig() {
|
||||
changed = true
|
||||
}
|
||||
if changed {
|
||||
if err := SaveConfig(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -311,9 +342,8 @@ func InitConfig() (*ClicdConfig, error) {
|
||||
if ok {
|
||||
AppConfig = legacy
|
||||
normalizeConfigDefaults(dataDir)
|
||||
if migrateLoadedConfig() {
|
||||
// Save below persists normalized legacy data into SQLite.
|
||||
}
|
||||
migrateLoadedConfig()
|
||||
// Always save legacy JSON data into SQLite.
|
||||
if err := SaveConfig(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -364,45 +394,144 @@ func InitConfig() (*ClicdConfig, error) {
|
||||
return AppConfig, nil
|
||||
}
|
||||
|
||||
func normalizeConfigDefaults(dataDir string) {
|
||||
func normalizeConfigDefaults(dataDir string) bool {
|
||||
changed := false
|
||||
if AppConfig.Port == 0 {
|
||||
AppConfig.Port = 8999
|
||||
changed = true
|
||||
}
|
||||
if AppConfig.NextVNCPort == 0 {
|
||||
AppConfig.NextVNCPort = 5900
|
||||
changed = true
|
||||
}
|
||||
if AppConfig.NextSSHPort == 0 {
|
||||
AppConfig.NextSSHPort = 22000
|
||||
changed = true
|
||||
}
|
||||
if AppConfig.NextContainerID == 0 {
|
||||
AppConfig.NextContainerID = 1
|
||||
changed = true
|
||||
}
|
||||
if AppConfig.DataDir == "" {
|
||||
AppConfig.DataDir = dataDir
|
||||
changed = true
|
||||
}
|
||||
if AppConfig.Containers == nil {
|
||||
AppConfig.Containers = make([]Container, 0)
|
||||
changed = true
|
||||
}
|
||||
if AppConfig.Snapshots == nil {
|
||||
AppConfig.Snapshots = make([]Snapshot, 0)
|
||||
changed = true
|
||||
}
|
||||
if AppConfig.SubUsers == nil {
|
||||
AppConfig.SubUsers = make([]SubUser, 0)
|
||||
changed = true
|
||||
}
|
||||
if AppConfig.ApiKeys == nil {
|
||||
AppConfig.ApiKeys = make([]ApiKeyConfig, 0)
|
||||
changed = true
|
||||
} else {
|
||||
for i := range AppConfig.ApiKeys {
|
||||
if len(AppConfig.ApiKeys[i].Scopes) == 0 {
|
||||
AppConfig.ApiKeys[i].Scopes = []string{"*"}
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if AppConfig.AuditLogs == nil {
|
||||
AppConfig.AuditLogs = make([]AuditLog, 0)
|
||||
changed = true
|
||||
}
|
||||
if AppConfig.Tasks == nil {
|
||||
AppConfig.Tasks = make([]SavedTask, 0)
|
||||
changed = true
|
||||
}
|
||||
if AppConfig.LoginLogs == nil {
|
||||
AppConfig.LoginLogs = make([]SavedLoginLog, 0)
|
||||
changed = true
|
||||
}
|
||||
if AppConfig.EnabledImages == nil {
|
||||
AppConfig.EnabledImages = make([]string, 0)
|
||||
changed = true
|
||||
}
|
||||
if AppConfig.Language == "" {
|
||||
AppConfig.Language = "zh"
|
||||
changed = true
|
||||
}
|
||||
if AppConfig.Language != "zh" && AppConfig.Language != "en" {
|
||||
AppConfig.Language = "zh"
|
||||
changed = true
|
||||
}
|
||||
if normalizeSSLDefaults() {
|
||||
changed = true
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
func NormalizeLanguage(language string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(language)) {
|
||||
case "en", "en-us", "en_us", "english":
|
||||
return "en"
|
||||
default:
|
||||
return "zh"
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeSSLDefaults() bool {
|
||||
changed := false
|
||||
previousMode := AppConfig.SSL.Mode
|
||||
AppConfig.SSL.Mode = NormalizeSSLMode(AppConfig.SSL.Mode)
|
||||
if AppConfig.SSL.Mode != previousMode {
|
||||
changed = true
|
||||
}
|
||||
if AppConfig.SSL.Mode == SSLModeDisabled {
|
||||
if AppConfig.SSL.Enabled {
|
||||
changed = true
|
||||
}
|
||||
AppConfig.SSL.Enabled = false
|
||||
}
|
||||
if AppConfig.SSLCertificates == nil {
|
||||
AppConfig.SSLCertificates = map[string]SSLConfig{}
|
||||
changed = true
|
||||
}
|
||||
for mode, cert := range AppConfig.SSLCertificates {
|
||||
cert.Mode = NormalizeSSLMode(cert.Mode)
|
||||
if cert.Mode == SSLModeDisabled {
|
||||
delete(AppConfig.SSLCertificates, mode)
|
||||
changed = true
|
||||
continue
|
||||
}
|
||||
if AppConfig.SSLCertificates[cert.Mode] != cert {
|
||||
changed = true
|
||||
}
|
||||
AppConfig.SSLCertificates[cert.Mode] = cert
|
||||
if mode != cert.Mode {
|
||||
delete(AppConfig.SSLCertificates, mode)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if AppConfig.SSL.Mode != SSLModeDisabled && AppConfig.SSL.CertPath != "" && AppConfig.SSL.KeyPath != "" {
|
||||
cert := AppConfig.SSL
|
||||
cert.Enabled = false
|
||||
if AppConfig.SSLCertificates[cert.Mode] != cert {
|
||||
changed = true
|
||||
}
|
||||
AppConfig.SSLCertificates[cert.Mode] = cert
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
func NormalizeSSLMode(value string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case SSLModeLetsEncrypt:
|
||||
return SSLModeLetsEncrypt
|
||||
case SSLModeSelfSigned:
|
||||
return SSLModeSelfSigned
|
||||
case SSLModeUploaded:
|
||||
return SSLModeUploaded
|
||||
default:
|
||||
return SSLModeDisabled
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
@@ -57,6 +57,28 @@ func encodeSavedTaskConfig(cfg savedTaskConfig) string {
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func encodeStringSlice(values []string) string {
|
||||
if len(values) == 0 {
|
||||
return ""
|
||||
}
|
||||
data, err := json.Marshal(values)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func decodeStringSlice(raw string) []string {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return nil
|
||||
}
|
||||
var values []string
|
||||
if err := json.Unmarshal([]byte(raw), &values); err != nil {
|
||||
return nil
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func getDBPath() string {
|
||||
cfgPath := getConfigPath()
|
||||
ext := filepath.Ext(cfgPath)
|
||||
@@ -185,7 +207,12 @@ func ensureSchema() error {
|
||||
prefix TEXT,
|
||||
ip_whitelist TEXT,
|
||||
created_at TEXT,
|
||||
last_used TEXT
|
||||
last_used TEXT,
|
||||
scopes TEXT,
|
||||
expires_at TEXT,
|
||||
disabled INTEGER,
|
||||
container_uuids TEXT,
|
||||
last_used_ip TEXT
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS audit_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -210,6 +237,8 @@ func ensureSchema() error {
|
||||
created_at TEXT,
|
||||
template_id TEXT,
|
||||
user TEXT,
|
||||
ip TEXT,
|
||||
user_agent TEXT,
|
||||
cfg_name TEXT,
|
||||
cfg_virtualization TEXT,
|
||||
cfg_template_id TEXT,
|
||||
@@ -263,9 +292,55 @@ func ensureSchema() error {
|
||||
return fmt.Errorf("failed to create sqlite schema: %v", err)
|
||||
}
|
||||
}
|
||||
return ensureSchemaMigrations()
|
||||
}
|
||||
|
||||
func ensureSchemaMigrations() error {
|
||||
for _, column := range []struct {
|
||||
table string
|
||||
name string
|
||||
def string
|
||||
}{
|
||||
{"api_keys", "scopes", "TEXT"},
|
||||
{"api_keys", "expires_at", "TEXT"},
|
||||
{"api_keys", "disabled", "INTEGER"},
|
||||
{"api_keys", "container_uuids", "TEXT"},
|
||||
{"api_keys", "last_used_ip", "TEXT"},
|
||||
{"tasks", "ip", "TEXT"},
|
||||
{"tasks", "user_agent", "TEXT"},
|
||||
} {
|
||||
if err := ensureColumn(column.table, column.name, column.def); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensureColumn(table, name, def string) error {
|
||||
rows, err := db.Query("PRAGMA table_info(" + table + ")")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var cid int
|
||||
var columnName, columnType string
|
||||
var notNull, pk int
|
||||
var defaultValue interface{}
|
||||
if err := rows.Scan(&cid, &columnName, &columnType, ¬Null, &defaultValue, &pk); err != nil {
|
||||
return err
|
||||
}
|
||||
if columnName == name {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = db.Exec("ALTER TABLE " + table + " ADD COLUMN " + name + " " + def)
|
||||
return err
|
||||
}
|
||||
|
||||
func loadConfigFromDB() (*ClicdConfig, bool, error) {
|
||||
meta := map[string]string{}
|
||||
rows, err := db.Query("SELECT key, value FROM app_meta")
|
||||
@@ -298,6 +373,13 @@ func loadConfigFromDB() (*ClicdConfig, bool, error) {
|
||||
NextSSHPort: atoi(meta["next_ssh_port"]),
|
||||
SetupComplete: atob(meta["setup_complete"]),
|
||||
SecurityAutoShutdown: atob(meta["security_auto_shutdown"]),
|
||||
Language: meta["language"],
|
||||
}
|
||||
if raw := strings.TrimSpace(meta["ssl"]); raw != "" {
|
||||
_ = json.Unmarshal([]byte(raw), &cfg.SSL)
|
||||
}
|
||||
if raw := strings.TrimSpace(meta["ssl_certificates"]); raw != "" {
|
||||
_ = json.Unmarshal([]byte(raw), &cfg.SSLCertificates)
|
||||
}
|
||||
|
||||
if cfg.Containers, err = loadContainers(); err != nil {
|
||||
@@ -391,6 +473,8 @@ func saveConfigToDB() error {
|
||||
}
|
||||
|
||||
func saveMeta(tx *sql.Tx) error {
|
||||
sslJSON, _ := json.Marshal(AppConfig.SSL)
|
||||
sslCertificatesJSON, _ := json.Marshal(AppConfig.SSLCertificates)
|
||||
values := map[string]string{
|
||||
"admin_user": AppConfig.AdminUser,
|
||||
"admin_pass_hash": AppConfig.AdminPassHash,
|
||||
@@ -402,6 +486,9 @@ func saveMeta(tx *sql.Tx) error {
|
||||
"next_ssh_port": strconv.Itoa(AppConfig.NextSSHPort),
|
||||
"setup_complete": btoa(AppConfig.SetupComplete),
|
||||
"security_auto_shutdown": btoa(AppConfig.SecurityAutoShutdown),
|
||||
"language": NormalizeLanguage(AppConfig.Language),
|
||||
"ssl": string(sslJSON),
|
||||
"ssl_certificates": string(sslCertificatesJSON),
|
||||
"schema_version": "1",
|
||||
"updated_at": time.Now().Format("2006-01-02 15:04:05"),
|
||||
}
|
||||
@@ -468,8 +555,10 @@ func saveSubUsers(tx *sql.Tx) error {
|
||||
|
||||
func saveAPIKeys(tx *sql.Tx) error {
|
||||
for _, k := range AppConfig.ApiKeys {
|
||||
if _, err := tx.Exec(`INSERT INTO api_keys(id, name, key_hash, prefix, ip_whitelist, created_at, last_used)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`, k.ID, k.Name, k.KeyHash, k.Prefix, k.IPWhitelist, k.CreatedAt, k.LastUsed); err != nil {
|
||||
scopes := encodeStringSlice(k.Scopes)
|
||||
containerUUIDs := encodeStringSlice(k.ContainerUUIDs)
|
||||
if _, err := tx.Exec(`INSERT INTO api_keys(id, name, key_hash, prefix, ip_whitelist, created_at, last_used, scopes, expires_at, disabled, container_uuids, last_used_ip)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, k.ID, k.Name, k.KeyHash, k.Prefix, k.IPWhitelist, k.CreatedAt, k.LastUsed, scopes, k.ExpiresAt, boolInt(k.Disabled), containerUUIDs, k.LastUsedIP); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -498,13 +587,13 @@ func saveTasksDB(tx *sql.Tx) error {
|
||||
for _, task := range AppConfig.Tasks {
|
||||
cfg := parseSavedTaskConfig(task.Config)
|
||||
if _, err := tx.Exec(`INSERT INTO tasks(
|
||||
id, type, container_id, container_name, status, error, created_at, template_id, user,
|
||||
id, type, container_id, container_name, status, error, created_at, template_id, user, ip, user_agent,
|
||||
cfg_name, cfg_virtualization, cfg_template_id, cfg_vcpu, cfg_cpu_percent, cfg_ram_mb, cfg_disk_gb,
|
||||
cfg_network_bw_mbps, cfg_monthly_traffic_gb, cfg_traffic_mode, cfg_traffic_in_gb,
|
||||
cfg_traffic_out_gb, cfg_io_speed_mbps, cfg_port_mapping_count, cfg_snapshot_limit,
|
||||
cfg_assign_ipv6, cfg_expires_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
task.ID, task.Type, task.ContainerID, task.ContainerName, task.Status, task.Error, task.CreatedAt, task.TemplateID, task.User,
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
task.ID, task.Type, task.ContainerID, task.ContainerName, task.Status, task.Error, task.CreatedAt, task.TemplateID, task.User, task.IP, task.UserAgent,
|
||||
cfg.Name, cfg.Virtualization, cfg.TemplateID, cfg.VCPU, cfg.CPUPercent, cfg.RAMMB, cfg.DiskGB,
|
||||
cfg.NetworkBWMbps, cfg.MonthlyTrafficGB, cfg.TrafficMode, cfg.TrafficInGB,
|
||||
cfg.TrafficOutGB, cfg.IOSpeedMBps, cfg.PortMappingCount, cfg.SnapshotLimit,
|
||||
@@ -669,7 +758,7 @@ func loadStringList(table, valueColumn, keyColumn, key string) ([]string, error)
|
||||
}
|
||||
|
||||
func loadAPIKeys() ([]ApiKeyConfig, error) {
|
||||
rows, err := db.Query(`SELECT id, name, key_hash, prefix, ip_whitelist, created_at, last_used FROM api_keys ORDER BY created_at, id`)
|
||||
rows, err := db.Query(`SELECT id, name, key_hash, prefix, ip_whitelist, created_at, last_used, scopes, expires_at, disabled, container_uuids, last_used_ip FROM api_keys ORDER BY created_at, id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -677,9 +766,16 @@ func loadAPIKeys() ([]ApiKeyConfig, error) {
|
||||
result := []ApiKeyConfig{}
|
||||
for rows.Next() {
|
||||
var k ApiKeyConfig
|
||||
if err := rows.Scan(&k.ID, &k.Name, &k.KeyHash, &k.Prefix, &k.IPWhitelist, &k.CreatedAt, &k.LastUsed); err != nil {
|
||||
var scopes, expiresAt, containerUUIDs, lastUsedIP sql.NullString
|
||||
var disabled sql.NullInt64
|
||||
if err := rows.Scan(&k.ID, &k.Name, &k.KeyHash, &k.Prefix, &k.IPWhitelist, &k.CreatedAt, &k.LastUsed, &scopes, &expiresAt, &disabled, &containerUUIDs, &lastUsedIP); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
k.Scopes = decodeStringSlice(scopes.String)
|
||||
k.ExpiresAt = expiresAt.String
|
||||
k.Disabled = disabled.Valid && disabled.Int64 != 0
|
||||
k.ContainerUUIDs = decodeStringSlice(containerUUIDs.String)
|
||||
k.LastUsedIP = lastUsedIP.String
|
||||
result = append(result, k)
|
||||
}
|
||||
return result, rows.Err()
|
||||
@@ -709,7 +805,7 @@ func loadAuditLogs() ([]AuditLog, error) {
|
||||
|
||||
func loadTasks() ([]SavedTask, error) {
|
||||
rows, err := db.Query(`SELECT
|
||||
id, type, container_id, container_name, status, error, created_at, template_id, user,
|
||||
id, type, container_id, container_name, status, error, created_at, template_id, user, ip, user_agent,
|
||||
cfg_name, cfg_virtualization, cfg_template_id, cfg_vcpu, cfg_cpu_percent, cfg_ram_mb, cfg_disk_gb,
|
||||
cfg_network_bw_mbps, cfg_monthly_traffic_gb, cfg_traffic_mode, cfg_traffic_in_gb,
|
||||
cfg_traffic_out_gb, cfg_io_speed_mbps, cfg_port_mapping_count, cfg_snapshot_limit,
|
||||
@@ -725,8 +821,9 @@ func loadTasks() ([]SavedTask, error) {
|
||||
var t SavedTask
|
||||
var cfg savedTaskConfig
|
||||
var assignIPv6 int
|
||||
var ip, userAgent sql.NullString
|
||||
if err := rows.Scan(
|
||||
&t.ID, &t.Type, &t.ContainerID, &t.ContainerName, &t.Status, &t.Error, &t.CreatedAt, &t.TemplateID, &t.User,
|
||||
&t.ID, &t.Type, &t.ContainerID, &t.ContainerName, &t.Status, &t.Error, &t.CreatedAt, &t.TemplateID, &t.User, &ip, &userAgent,
|
||||
&cfg.Name, &cfg.Virtualization, &cfg.TemplateID, &cfg.VCPU, &cfg.CPUPercent, &cfg.RAMMB, &cfg.DiskGB,
|
||||
&cfg.NetworkBWMbps, &cfg.MonthlyTrafficGB, &cfg.TrafficMode, &cfg.TrafficInGB,
|
||||
&cfg.TrafficOutGB, &cfg.IOSpeedMBps, &cfg.PortMappingCount, &cfg.SnapshotLimit,
|
||||
@@ -734,6 +831,8 @@ func loadTasks() ([]SavedTask, error) {
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t.IP = ip.String
|
||||
t.UserAgent = userAgent.String
|
||||
cfg.AssignIPv6 = assignIPv6 != 0
|
||||
result = append(result, t)
|
||||
configs = append(configs, cfg)
|
||||
|
||||
@@ -37,6 +37,7 @@ type Manager struct {
|
||||
}
|
||||
|
||||
const ipv6GatewayLinkLocal = "fe80::1"
|
||||
const libvirtDefaultNetworkMarker = "/var/lib/clicd/kvm/default-network.created"
|
||||
|
||||
type usageSample struct {
|
||||
CPUUsec uint64
|
||||
@@ -1476,6 +1477,9 @@ func ensureDefaultNetwork() error {
|
||||
if out, err := exec.Command("virsh", "net-define", tmpFile).CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("failed to define libvirt default network: %v, output: %s", err, string(out))
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(libvirtDefaultNetworkMarker), 0755); err == nil {
|
||||
_ = os.WriteFile(libvirtDefaultNetworkMarker, []byte("created-by-clicd\n"), 0644)
|
||||
}
|
||||
}
|
||||
// Start and autostart the default network
|
||||
if out, err := exec.Command("virsh", "net-info", "default").Output(); err == nil {
|
||||
|
||||
+204
-50
@@ -390,7 +390,7 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
|
||||
fmt.Printf("Warning: failed to install IPv6 init in %s: %v\n", lxcName, err)
|
||||
}
|
||||
}
|
||||
if err := m.preconfigureSSH(rootfsPath, sshPassword, cfg.TemplateID); err != nil {
|
||||
if err := m.preconfigureSSH(rootfsPath, cfg.TemplateID); err != nil {
|
||||
fmt.Printf("Warning: failed to pre-configure SSH in %s: %v\n", lxcName, err)
|
||||
}
|
||||
|
||||
@@ -402,8 +402,7 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
|
||||
|
||||
// Set root password AFTER shiftRootfsForUnprivileged,
|
||||
// otherwise /etc/shadow ownership breaks and SSHD cannot authenticate.
|
||||
if err := m.runRootfsCommand(rootfsPath,
|
||||
"sh", "-c", fmt.Sprintf("printf '%%s:%%s\\n' root %s | chpasswd", shellQuote(sshPassword))); err != nil {
|
||||
if err := m.setRootfsPassword(rootfsPath, sshPassword); err != nil {
|
||||
fmt.Printf("Warning: failed to set root password in %s: %v\n", lxcName, err)
|
||||
}
|
||||
|
||||
@@ -472,11 +471,11 @@ IPv6AcceptRA=no
|
||||
}
|
||||
|
||||
// preconfigureSSH installs and configures SSH directly in the rootfs before first boot.
|
||||
func (m *Manager) preconfigureSSH(rootfsPath, password, templateID string) error {
|
||||
func (m *Manager) preconfigureSSH(rootfsPath, templateID string) error {
|
||||
_ = templateID
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second)
|
||||
defer cancel()
|
||||
cmd, err := m.rootfsCommand(rootfsPath, "sh", "-c", sshSetupScript(password, false))
|
||||
cmd, err := m.rootfsCommand(rootfsPath, "sh", "-c", sshSetupScript(false))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -993,6 +992,27 @@ func parseSubIDRange(path, user string) (int, error) {
|
||||
return 0, fmt.Errorf("%s must contain a %s subordinate id range with at least 65536 ids", path, user)
|
||||
}
|
||||
|
||||
func (m *Manager) ensureUnprivilegedLXCPathAccess(lxcName string) error {
|
||||
// Unprivileged container root maps to a subordinate host UID, so it needs
|
||||
// execute permission on the LXC parent and container directories to reach
|
||||
// rootfs. Some distributions create /var/lib/lxc as 750/700, which causes
|
||||
// lxc-start to abort with "Could not access /var/lib/lxc".
|
||||
for _, path := range []string{m.LxcPath, filepath.Join(m.LxcPath, lxcName)} {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mode := info.Mode().Perm()
|
||||
if mode&0001 != 0 {
|
||||
continue
|
||||
}
|
||||
if err := os.Chmod(path, mode|0001); err != nil {
|
||||
return fmt.Errorf("failed to fix LXC path permissions for %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) shiftRootfsForUnprivileged(lxcName string) error {
|
||||
uidBase, gidBase, err := unprivilegedIDMap()
|
||||
if err != nil {
|
||||
@@ -1000,6 +1020,9 @@ func (m *Manager) shiftRootfsForUnprivileged(lxcName string) error {
|
||||
}
|
||||
rootfsPath := filepath.Join(m.LxcPath, lxcName, "rootfs")
|
||||
marker := filepath.Join(rootfsPath, ".clicd-unprivileged-shifted")
|
||||
if err := m.ensureUnprivilegedLXCPathAccess(lxcName); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := os.Stat(marker); err == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -1626,7 +1649,7 @@ func (m *Manager) EnsureSSH(id int) error {
|
||||
config.SaveConfig()
|
||||
}
|
||||
|
||||
script := sshSetupScript(c.SSHPassword, true)
|
||||
script := sshSetupScript(true)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second)
|
||||
defer cancel()
|
||||
@@ -1638,6 +1661,9 @@ func (m *Manager) EnsureSSH(id int) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to configure SSH in container %d: %v, output: %s", id, err, string(output))
|
||||
}
|
||||
if err := m.quickEnsureSSHPassword(lxcName, c.SSHPassword); err != nil {
|
||||
return fmt.Errorf("failed to set SSH password in container %d: %v", id, err)
|
||||
}
|
||||
|
||||
if c.IP == "" {
|
||||
if ip, ipErr := m.GetContainerIP(lxcName); ipErr == nil && ip != "" {
|
||||
@@ -1656,13 +1682,13 @@ func (m *Manager) EnsureSSH(id int) error {
|
||||
}
|
||||
|
||||
func (m *Manager) quickEnsureSSHPassword(lxcName, password string) error {
|
||||
if password == "" {
|
||||
return fmt.Errorf("empty SSH password")
|
||||
if err := validateRootPassword(password); err != nil {
|
||||
return err
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, "lxc-attach", "-n", lxcName, "--", "sh", "-c",
|
||||
fmt.Sprintf("printf '%%s:%%s\\n' root %s | chpasswd", shellQuote(password)))
|
||||
cmd := exec.CommandContext(ctx, "lxc-attach", "-n", lxcName, "--", "chpasswd")
|
||||
cmd.Stdin = strings.NewReader(rootPasswordInput(password))
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update SSH password quickly: %v, output: %s", err, string(output))
|
||||
@@ -1670,6 +1696,20 @@ func (m *Manager) quickEnsureSSHPassword(lxcName, password string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateRootPassword(password string) error {
|
||||
if password == "" {
|
||||
return fmt.Errorf("empty SSH password")
|
||||
}
|
||||
if strings.ContainsAny(password, "\r\n") || strings.ContainsRune(password, '\x00') {
|
||||
return fmt.Errorf("SSH password contains unsupported control characters")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func rootPasswordInput(password string) string {
|
||||
return "root:" + password + "\n"
|
||||
}
|
||||
|
||||
func (m *Manager) containerPortListening(lxcName string, port int) bool {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
@@ -1677,9 +1717,8 @@ func (m *Manager) containerPortListening(lxcName string, port int) bool {
|
||||
return exec.CommandContext(ctx, "lxc-attach", "-n", lxcName, "--", "sh", "-c", check).Run() == nil
|
||||
}
|
||||
|
||||
func sshSetupScript(password string, startService bool) string {
|
||||
func sshSetupScript(startService bool) string {
|
||||
script := `set -u
|
||||
ROOT_PASSWORD=` + shellQuote(password) + `
|
||||
|
||||
# DNS setup: handle both traditional /etc/resolv.conf and systemd-resolved (Ubuntu 24.04).
|
||||
# On modern distros, /etc/resolv.conf is a symlink managed by systemd-resolved.
|
||||
@@ -1803,11 +1842,6 @@ set_sshd_option KbdInteractiveAuthentication no
|
||||
set_sshd_option ChallengeResponseAuthentication no
|
||||
set_sshd_option UsePAM no
|
||||
|
||||
if [ -n "$ROOT_PASSWORD" ]; then
|
||||
printf '%s:%s\n' root "$ROOT_PASSWORD" | chpasswd || exit 31
|
||||
passwd -u root >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
if command -v rc-update >/dev/null 2>&1; then
|
||||
rc-update add sshd default >/dev/null 2>&1 || true
|
||||
fi
|
||||
@@ -1888,16 +1922,11 @@ func (m *Manager) ResetSSHPassword(id int, password string) (string, error) {
|
||||
return "", err
|
||||
}
|
||||
rootfsPath := filepath.Join(m.LxcPath, lxcName, "rootfs")
|
||||
if err := m.preconfigureSSH(rootfsPath, newPassword, c.Template); err != nil {
|
||||
if err := m.preconfigureSSH(rootfsPath, c.Template); err != nil {
|
||||
return "", fmt.Errorf("failed to configure SSH: %v", err)
|
||||
}
|
||||
cmd, err := m.rootfsCommand(rootfsPath, "sh", "-c", fmt.Sprintf("printf '%%s:%%s\\n' root %s | chpasswd", shellQuote(newPassword)))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to set password: %v, output: %s", err, string(output))
|
||||
if err := m.setRootfsPassword(rootfsPath, newPassword); err != nil {
|
||||
return "", fmt.Errorf("failed to set password: %v", err)
|
||||
}
|
||||
c.SSHPassword = newPassword
|
||||
config.SaveConfig()
|
||||
@@ -1911,6 +1940,10 @@ func (m *Manager) rootfsCommand(rootfsPath string, args ...string) (*exec.Cmd, e
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
safeArgs, err := safeRootfsCommandArgs(args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
marker := filepath.Join(cleanRootfsPath, ".clicd-unprivileged-shifted")
|
||||
if _, err := os.Stat(marker); err == nil {
|
||||
@@ -1921,11 +1954,11 @@ func (m *Manager) rootfsCommand(rootfsPath string, args ...string) (*exec.Cmd, e
|
||||
"-m", fmt.Sprintf("g:0:%d:65536", gidBase),
|
||||
"--", "chroot", "--", cleanRootfsPath,
|
||||
}
|
||||
cmdArgs = append(cmdArgs, args...)
|
||||
cmdArgs = append(cmdArgs, safeArgs...)
|
||||
return exec.Command("lxc-usernsexec", cmdArgs...), nil
|
||||
}
|
||||
}
|
||||
cmdArgs := append([]string{"--", cleanRootfsPath}, args...)
|
||||
cmdArgs := append([]string{"--", cleanRootfsPath}, safeArgs...)
|
||||
return exec.Command("chroot", cmdArgs...), nil
|
||||
}
|
||||
|
||||
@@ -1937,6 +1970,58 @@ func (m *Manager) runRootfsCommand(rootfsPath string, args ...string) error {
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func (m *Manager) setRootfsPassword(rootfsPath, password string) error {
|
||||
if err := validateRootPassword(password); err != nil {
|
||||
return err
|
||||
}
|
||||
cmd, err := m.rootfsCommand(rootfsPath, "chpasswd")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cmd.Stdin = strings.NewReader(rootPasswordInput(password))
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("%v, output: %s", err, string(output))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func safeRootfsCommandArgs(args []string) ([]string, error) {
|
||||
if len(args) == 0 {
|
||||
return nil, fmt.Errorf("empty rootfs command")
|
||||
}
|
||||
allowed := map[string]bool{
|
||||
"chpasswd": true,
|
||||
"rc-update": true,
|
||||
"sh": true,
|
||||
"systemctl": true,
|
||||
}
|
||||
if !allowed[args[0]] || strings.HasPrefix(args[0], "-") || strings.Contains(args[0], "/") {
|
||||
return nil, fmt.Errorf("rootfs command is not allowed: %s", args[0])
|
||||
}
|
||||
for _, arg := range args {
|
||||
if strings.ContainsRune(arg, '\x00') {
|
||||
return nil, fmt.Errorf("rootfs command argument contains NUL byte")
|
||||
}
|
||||
}
|
||||
if args[0] == "sh" {
|
||||
if len(args) != 3 || args[1] != "-c" {
|
||||
return nil, fmt.Errorf("unsupported rootfs shell invocation")
|
||||
}
|
||||
if !isCLICDManagedRootfsScript(args[2]) {
|
||||
return nil, fmt.Errorf("refusing unmanaged rootfs shell script")
|
||||
}
|
||||
}
|
||||
return append([]string(nil), args...), nil
|
||||
}
|
||||
|
||||
func isCLICDManagedRootfsScript(script string) bool {
|
||||
return strings.Contains(script, "99-clicd.conf") &&
|
||||
strings.Contains(script, "install_sshd") &&
|
||||
!strings.Contains(script, "ROOT_PASSWORD") &&
|
||||
!strings.Contains(script, "chpasswd")
|
||||
}
|
||||
|
||||
func (m *Manager) safeRootfsPath(rootfsPath string) (string, error) {
|
||||
if rootfsPath == "" {
|
||||
return "", fmt.Errorf("empty rootfs path")
|
||||
@@ -1969,6 +2054,13 @@ func (m *Manager) safeRootfsPath(rootfsPath string) (string, error) {
|
||||
if rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) || filepath.IsAbs(rel) {
|
||||
return "", fmt.Errorf("refusing unsafe rootfs path: %s", cleanRootfsPath)
|
||||
}
|
||||
parts := strings.Split(rel, string(os.PathSeparator))
|
||||
if len(parts) != 2 || parts[1] != "rootfs" {
|
||||
return "", fmt.Errorf("refusing nested or malformed rootfs path: %s", cleanRootfsPath)
|
||||
}
|
||||
if strings.HasPrefix(parts[0], "-") || !regexp.MustCompile(`^[A-Za-z0-9_.-]+$`).MatchString(parts[0]) {
|
||||
return "", fmt.Errorf("refusing unsafe container directory name: %s", parts[0])
|
||||
}
|
||||
return cleanRootfsPath, nil
|
||||
}
|
||||
|
||||
@@ -2211,6 +2303,85 @@ func (m *Manager) ImportExistingClicdContainers() ([]config.Container, error) {
|
||||
return imported, nil
|
||||
}
|
||||
|
||||
func (m *Manager) replaceRootfsFromTemplate(lxcName string, tmpl *Template) error {
|
||||
if tmpl == nil {
|
||||
return fmt.Errorf("template is nil")
|
||||
}
|
||||
tmpName := fmt.Sprintf("clicd-reinstall-%s-%s", lxcName, generateRandomString(8))
|
||||
tmpDir := filepath.Join(m.LxcPath, tmpName)
|
||||
if err := os.RemoveAll(tmpDir); err != nil {
|
||||
return fmt.Errorf("failed to clean temporary reinstall directory: %v", err)
|
||||
}
|
||||
defer m.cleanupTemporaryContainer(tmpName)
|
||||
|
||||
args := []string{
|
||||
"-n", tmpName,
|
||||
"-t", "download",
|
||||
"--",
|
||||
"-d", tmpl.Distro,
|
||||
"-r", tmpl.Release,
|
||||
"-a", tmpl.Arch,
|
||||
}
|
||||
if tmpl.Variant != "" {
|
||||
args = append(args, "--variant", tmpl.Variant)
|
||||
}
|
||||
output, err := exec.Command("lxc-create", args...).CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to download replacement rootfs: %v, output: %s", err, string(output))
|
||||
}
|
||||
|
||||
tmpRootfs := filepath.Join(tmpDir, "rootfs")
|
||||
if !rootfsHasInit(tmpRootfs) {
|
||||
return fmt.Errorf("downloaded replacement rootfs is invalid: init not found")
|
||||
}
|
||||
|
||||
rootfsPath := filepath.Join(m.LxcPath, lxcName, "rootfs")
|
||||
if err := m.ensureDiskImageMounted(lxcName); err != nil {
|
||||
return err
|
||||
}
|
||||
m.unmountRootfsChildMounts(rootfsPath)
|
||||
if err := os.MkdirAll(rootfsPath, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := removeDirectoryContents(rootfsPath); err != nil {
|
||||
return fmt.Errorf("failed to clear old rootfs: %v", err)
|
||||
}
|
||||
if err := copyRootfsContents(tmpRootfs, rootfsPath); err != nil {
|
||||
return err
|
||||
}
|
||||
if !rootfsHasInit(rootfsPath) {
|
||||
return fmt.Errorf("replacement rootfs copy failed: init not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) cleanupTemporaryContainer(lxcName string) {
|
||||
exec.Command("lxc-stop", "-n", lxcName, "-k").Run()
|
||||
exec.Command("lxc-destroy", "-n", lxcName, "-f").Run()
|
||||
os.RemoveAll(filepath.Join(m.LxcPath, lxcName))
|
||||
}
|
||||
|
||||
func removeDirectoryContents(dir string) error {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if err := os.RemoveAll(filepath.Join(dir, entry.Name())); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func copyRootfsContents(src, dst string) error {
|
||||
output, err := exec.Command("cp", "-a", src+string(os.PathSeparator)+".", dst+string(os.PathSeparator)).CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to copy replacement rootfs: %v, output: %s", err, string(output))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReinstallContainer reinstalls the container OS
|
||||
func (m *Manager) ReinstallContainer(id int, templateID string) error {
|
||||
c := config.FindContainer(id)
|
||||
@@ -2234,26 +2405,10 @@ func (m *Manager) ReinstallContainer(id int, templateID string) error {
|
||||
// Clean port mappings temporarily
|
||||
m.CleanPortMappings(id)
|
||||
|
||||
// Destroy old LXC but keep config
|
||||
exec.Command("lxc-stop", "-n", lxcName, "-k").Run()
|
||||
exec.Command("lxc-destroy", "-n", lxcName, "-f").Run()
|
||||
rootfs := filepath.Join(m.LxcPath, lxcName, "rootfs")
|
||||
exec.Command("umount", "-R", "-l", rootfs).Run()
|
||||
os.RemoveAll(rootfs)
|
||||
os.Remove(filepath.Join(m.LxcPath, lxcName, "rootfs.img"))
|
||||
|
||||
// Create new container with same LXC name (preserves ID)
|
||||
cmd := exec.Command("lxc-create",
|
||||
"-n", lxcName,
|
||||
"-t", "download",
|
||||
"--",
|
||||
"-d", tmpl.Distro,
|
||||
"-r", tmpl.Release,
|
||||
"-a", tmpl.Arch,
|
||||
)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("lxc-create failed: %v, output: %s", err, string(output))
|
||||
// Download the new OS into a temporary container, then replace only the
|
||||
// existing rootfs. The target container directory and config are preserved.
|
||||
if err := m.replaceRootfsFromTemplate(lxcName, tmpl); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := m.applyDiskLimit(lxcName, c.DiskGB); err != nil {
|
||||
@@ -2293,14 +2448,13 @@ func (m *Manager) ReinstallContainer(id int, templateID string) error {
|
||||
if c.SSHPassword == "" {
|
||||
c.SSHPassword = generateRandomString(16)
|
||||
}
|
||||
if err := m.preconfigureSSH(rootfsPath, c.SSHPassword, templateID); err != nil {
|
||||
if err := m.preconfigureSSH(rootfsPath, templateID); err != nil {
|
||||
fmt.Printf("Warning: failed to pre-configure SSH in %s after reinstall: %v\n", lxcName, err)
|
||||
}
|
||||
if err := m.shiftRootfsForUnprivileged(lxcName); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := m.runRootfsCommand(rootfsPath,
|
||||
"sh", "-c", fmt.Sprintf("printf '%%s:%%s\\n' root %s | chpasswd", shellQuote(c.SSHPassword))); err != nil {
|
||||
if err := m.setRootfsPassword(rootfsPath, c.SSHPassword); err != nil {
|
||||
fmt.Printf("Warning: failed to set root password in %s after reinstall: %v\n", lxcName, err)
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRootfsCommandAddsSeparatorAndPreservesArgs(t *testing.T) {
|
||||
func TestRootfsCommandAddsSeparatorForAllowedCommand(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
rootfs := filepath.Join(base, "ct-1", "rootfs")
|
||||
if err := os.MkdirAll(rootfs, 0755); err != nil {
|
||||
@@ -16,18 +16,31 @@ func TestRootfsCommandAddsSeparatorAndPreservesArgs(t *testing.T) {
|
||||
}
|
||||
|
||||
m := &Manager{LxcPath: base}
|
||||
cmd, err := m.rootfsCommand(rootfs, "sh", "-c", "true", "--flag")
|
||||
cmd, err := m.rootfsCommand(rootfs, "chpasswd")
|
||||
if err != nil {
|
||||
t.Fatalf("rootfsCommand returned error: %v", err)
|
||||
}
|
||||
|
||||
want := []string{"chroot", "--", rootfs, "sh", "-c", "true", "--flag"}
|
||||
want := []string{"chroot", "--", rootfs, "chpasswd"}
|
||||
if !reflect.DeepEqual(cmd.Args, want) {
|
||||
t.Fatalf("cmd.Args = %#v, want %#v", cmd.Args, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootfsCommandAllowsLeadingDashContainerName(t *testing.T) {
|
||||
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 {
|
||||
@@ -35,14 +48,8 @@ func TestRootfsCommandAllowsLeadingDashContainerName(t *testing.T) {
|
||||
}
|
||||
|
||||
m := &Manager{LxcPath: base}
|
||||
cmd, err := m.rootfsCommand(rootfs, "true")
|
||||
if err != nil {
|
||||
t.Fatalf("rootfsCommand returned error: %v", err)
|
||||
}
|
||||
|
||||
want := []string{"chroot", "--", rootfs, "true"}
|
||||
if !reflect.DeepEqual(cmd.Args, want) {
|
||||
t.Fatalf("cmd.Args = %#v, want %#v", cmd.Args, want)
|
||||
if _, err := m.rootfsCommand(rootfs, "chpasswd"); err == nil {
|
||||
t.Fatal("rootfsCommand allowed leading-dash container name")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,7 +71,7 @@ func TestRootfsCommandRejectsUnsafeRootfsPaths(t *testing.T) {
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if _, err := m.rootfsCommand(tc.path, "true"); err == nil {
|
||||
if _, err := m.rootfsCommand(tc.path, "chpasswd"); err == nil {
|
||||
t.Fatalf("rootfsCommand(%q) returned nil error", tc.path)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
@@ -23,7 +24,7 @@ func corsMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
||||
w.Header().Set("Vary", "Origin")
|
||||
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||
}
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-API-Key")
|
||||
|
||||
if r.Method == http.MethodOptions {
|
||||
@@ -71,11 +72,14 @@ func isLoopbackHost(host string) bool {
|
||||
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)))
|
||||
@@ -86,6 +90,7 @@ func setupRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/images/enabled", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleEnabledImages))))
|
||||
mux.HandleFunc("/api/dashboard", corsMiddleware(api.AdminMiddleware(api.HandleDashboard)))
|
||||
mux.HandleFunc("/api/host-info", corsMiddleware(api.AdminMiddleware(api.HandleHostInfo)))
|
||||
mux.HandleFunc("/api/host-report", corsMiddleware(api.AdminMiddleware(api.HandleHostReport)))
|
||||
mux.HandleFunc("/api/snapshots", corsMiddleware(api.AdminMiddleware(api.HandleSnapshots)))
|
||||
mux.HandleFunc("/api/routing", corsMiddleware(api.AdminMiddleware(api.HandleRouting)))
|
||||
mux.HandleFunc("/api/ipv6/status", corsMiddleware(api.AdminMiddleware(api.HandleIPv6Status)))
|
||||
@@ -113,6 +118,51 @@ func setupRoutes(mux *http.ServeMux) {
|
||||
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", 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))
|
||||
|
||||
@@ -163,5 +213,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 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,7 +1,7 @@
|
||||
package version
|
||||
|
||||
var (
|
||||
Version = "1.1.2"
|
||||
Version = "1.1.9"
|
||||
Repo = "MengMengCode/CLICD"
|
||||
)
|
||||
|
||||
|
||||
+28
-2
@@ -50,6 +50,7 @@ func main() {
|
||||
|
||||
// Start security scanner
|
||||
api.InitScanner()
|
||||
api.StartSSLRenewalMonitor()
|
||||
|
||||
// Ensure iptables FORWARD rules allow managed bridge traffic.
|
||||
lxc.EnsureForwardRules("lxcbr0")
|
||||
@@ -106,8 +107,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"
|
||||
}
|
||||
|
||||
@@ -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 |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "clicd-frontend",
|
||||
"private": true,
|
||||
"version": "1.1.2",
|
||||
"version": "1.1.9",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -8,6 +8,7 @@ import ContainerDetail from './pages/ContainerDetail'
|
||||
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'
|
||||
@@ -64,6 +65,7 @@ function App() {
|
||||
<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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 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">
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Code2,
|
||||
Cpu,
|
||||
Camera,
|
||||
LayoutDashboard,
|
||||
LogOut,
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
UserCog,
|
||||
} from 'lucide-react'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { useLanguage } from '../contexts/LanguageContext'
|
||||
import { useTheme } from '../contexts/ThemeContext'
|
||||
import { getVersion } from '../services/api'
|
||||
import AppIcon from './AppIcon'
|
||||
@@ -44,11 +46,23 @@ function GitHubIcon({ className = '' }: { className?: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
function LanguageIcon({ className = '' }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<path
|
||||
d="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(() => {
|
||||
@@ -71,6 +85,7 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
|
||||
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')
|
||||
|
||||
@@ -97,7 +112,7 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="p-1 rounded hover:bg-gray-100 text-gray-500 dark:hover:bg-gray-800 dark:text-gray-400"
|
||||
title="切换侧边栏"
|
||||
title={t('切换侧边栏')}
|
||||
>
|
||||
{collapsed ? (
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
@@ -222,6 +237,18 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
|
||||
{!collapsed && <span>API 集成</span>}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => navigate('/host-report')}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
|
||||
isHostReportPage
|
||||
? 'bg-black text-white dark:bg-white dark:text-black'
|
||||
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'
|
||||
}`}
|
||||
>
|
||||
<Cpu className="w-4 h-4" />
|
||||
{!collapsed && <span>宿主机信息</span>}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => navigate('/settings')}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
|
||||
@@ -239,18 +266,28 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
|
||||
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 p-2 space-y-1">
|
||||
{/* Theme Toggle */}
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm text-gray-600 hover:bg-gray-100 transition-colors dark:text-gray-400 dark:hover:bg-gray-800"
|
||||
title={theme === 'dark' ? '切换亮色模式' : '切换暗黑模式'}
|
||||
>
|
||||
{theme === 'dark' ? (
|
||||
<Sun className="w-4 h-4" />
|
||||
) : (
|
||||
<Moon className="w-4 h-4" />
|
||||
)}
|
||||
{!collapsed && <span>{theme === 'dark' ? '亮色模式' : '暗黑模式'}</span>}
|
||||
</button>
|
||||
<div className={collapsed ? 'space-y-1' : 'flex items-center gap-1'}>
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className={`${collapsed ? 'w-full justify-center' : 'flex-1'} flex items-center gap-3 px-3 py-2.5 rounded-md text-sm text-gray-600 hover:bg-gray-100 transition-colors dark:text-gray-400 dark:hover:bg-gray-800`}
|
||||
title={t(theme === 'dark' ? '切换亮色模式' : '切换暗黑模式')}
|
||||
>
|
||||
{theme === 'dark' ? (
|
||||
<Sun className="w-4 h-4" />
|
||||
) : (
|
||||
<Moon className="w-4 h-4" />
|
||||
)}
|
||||
{!collapsed && <span>{theme === 'dark' ? '亮色模式' : '暗黑模式'}</span>}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => { void toggleLanguage() }}
|
||||
className={`${collapsed ? 'w-full' : '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 && (
|
||||
|
||||
@@ -1,8 +1,39 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Monitor, RefreshCw, Send, X } from 'lucide-react'
|
||||
import RFB from '@novnc/novnc'
|
||||
import RFBModule from '@novnc/novnc/lib/rfb'
|
||||
import { createVNCTicket, getWebVNCUrl } from '../services/api'
|
||||
|
||||
type RFBConstructor = new (
|
||||
target: HTMLElement,
|
||||
url: string,
|
||||
options?: { credentials?: Record<string, string>; shared?: boolean; repeaterID?: string; wsProtocols?: string[] }
|
||||
) => RFBInstance
|
||||
|
||||
interface RFBInstance extends EventTarget {
|
||||
scaleViewport: boolean
|
||||
resizeSession: boolean
|
||||
focusOnClick: boolean
|
||||
viewOnly: boolean
|
||||
qualityLevel: number
|
||||
compressionLevel: number
|
||||
background: string
|
||||
disconnect(): void
|
||||
sendCtrlAltDel(): void
|
||||
}
|
||||
|
||||
const RFB = resolveRFBConstructor(RFBModule)
|
||||
|
||||
function resolveRFBConstructor(moduleValue: unknown): RFBConstructor {
|
||||
if (typeof moduleValue === 'function') {
|
||||
return moduleValue as RFBConstructor
|
||||
}
|
||||
const maybeDefault = (moduleValue as { default?: unknown })?.default
|
||||
if (typeof maybeDefault === 'function') {
|
||||
return maybeDefault as RFBConstructor
|
||||
}
|
||||
throw new Error('noVNC RFB constructor is unavailable')
|
||||
}
|
||||
|
||||
interface WebVNCViewerProps {
|
||||
containerName: string
|
||||
onClose: () => void
|
||||
@@ -10,7 +41,7 @@ interface WebVNCViewerProps {
|
||||
|
||||
export default function WebVNCViewer({ containerName, onClose }: WebVNCViewerProps) {
|
||||
const screenRef = useRef<HTMLDivElement>(null)
|
||||
const rfbRef = useRef<RFB | null>(null)
|
||||
const rfbRef = useRef<RFBInstance | null>(null)
|
||||
const [status, setStatus] = useState<'connecting' | 'connected' | 'disconnected' | 'error'>('connecting')
|
||||
const [errorMsg, setErrorMsg] = useState('')
|
||||
|
||||
@@ -21,6 +52,46 @@ export default function WebVNCViewer({ containerName, onClose }: WebVNCViewerPro
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@@ -40,6 +111,7 @@ export default function WebVNCViewer({ containerName, onClose }: WebVNCViewerPro
|
||||
setErrorMsg(error.response?.data?.message || 'WebVNC ticket 创建失败,请重新登录后再试')
|
||||
return
|
||||
}
|
||||
|
||||
if (!ticket) {
|
||||
setStatus('error')
|
||||
setErrorMsg('WebVNC ticket 为空,请重新登录后再试')
|
||||
@@ -47,16 +119,17 @@ export default function WebVNCViewer({ containerName, onClose }: WebVNCViewerPro
|
||||
}
|
||||
|
||||
try {
|
||||
const rfb = new RFB(target, getWebVNCUrl(containerName, ticket))
|
||||
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('connect', () => setStatus('connected'))
|
||||
rfb.addEventListener('disconnect', (event) => {
|
||||
const detail = (event as CustomEvent<{ clean?: boolean }>).detail
|
||||
setStatus((current) => current === 'error' ? current : 'disconnected')
|
||||
@@ -76,7 +149,8 @@ export default function WebVNCViewer({ containerName, onClose }: WebVNCViewerPro
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
setStatus('error')
|
||||
setErrorMsg('WebVNC 初始化失败')
|
||||
const message = err instanceof Error && err.message ? `:${err.message}` : ''
|
||||
setErrorMsg(`WebVNC 初始化失败${message}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,35 +163,31 @@ export default function WebVNCViewer({ containerName, onClose }: WebVNCViewerPro
|
||||
}, [containerName])
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden h-full flex flex-col">
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-b border-gray-200 bg-gray-50 shrink-0">
|
||||
<div className="flex h-full flex-col overflow-hidden rounded-lg border border-gray-200 bg-white">
|
||||
<div className="flex shrink-0 items-center justify-between border-b border-gray-200 bg-gray-50 px-4 py-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Monitor className="w-4 h-4 text-gray-600" />
|
||||
<Monitor className="h-4 w-4 text-gray-600" />
|
||||
<span className="text-sm font-medium text-black">WebVNC - {containerName}</span>
|
||||
{status === 'connected' && <span className="text-xs px-1.5 py-0.5 rounded bg-green-100 text-green-700">已连接</span>}
|
||||
{status === 'connecting' && <span className="text-xs px-1.5 py-0.5 rounded bg-yellow-100 text-yellow-700">连接中...</span>}
|
||||
{status === 'disconnected' && <span className="text-xs px-1.5 py-0.5 rounded bg-gray-100 text-gray-600">已断开</span>}
|
||||
{status === 'error' && <span className="text-xs px-1.5 py-0.5 rounded bg-red-100 text-red-700">连接失败</span>}
|
||||
{status === 'connected' && <span className="rounded bg-green-100 px-1.5 py-0.5 text-xs text-green-700">已连接</span>}
|
||||
{status === 'connecting' && <span className="rounded bg-yellow-100 px-1.5 py-0.5 text-xs text-yellow-700">连接中...</span>}
|
||||
{status === 'disconnected' && <span className="rounded bg-gray-100 px-1.5 py-0.5 text-xs text-gray-600">已断开</span>}
|
||||
{status === 'error' && <span className="rounded bg-red-100 px-1.5 py-0.5 text-xs text-red-700">连接失败</span>}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => rfbRef.current?.sendCtrlAltDel()}
|
||||
className="inline-flex items-center gap-1 px-2 py-1.5 hover:bg-gray-200 rounded text-gray-500 text-xs"
|
||||
title="发送 Ctrl+Alt+Del"
|
||||
>
|
||||
<Send className="w-3.5 h-3.5" />
|
||||
<button onClick={() => rfbRef.current?.sendCtrlAltDel()} className="inline-flex items-center gap-1 rounded px-2 py-1.5 text-xs text-gray-500 hover:bg-gray-200" title="发送 Ctrl+Alt+Del">
|
||||
<Send className="h-3.5 w-3.5" />
|
||||
Ctrl+Alt+Del
|
||||
</button>
|
||||
<button onClick={connect} className="p-1.5 hover:bg-gray-200 rounded text-gray-500 text-xs" title="重新连接">
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
<button onClick={connect} className="rounded p-1.5 text-xs text-gray-500 hover:bg-gray-200" title="重新连接">
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button onClick={onClose} className="p-1.5 hover:bg-gray-200 rounded text-gray-500" title="关闭">
|
||||
<X className="w-4 h-4" />
|
||||
<button onClick={onClose} className="rounded p-1.5 text-gray-500 hover:bg-gray-200" title="关闭">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative flex-1 min-h-0 bg-black overflow-hidden">
|
||||
<div className="relative min-h-0 flex-1 overflow-hidden bg-black">
|
||||
<div ref={screenRef} className="h-full w-full [&>div]:h-full [&>div]:w-full [&_canvas]:block" />
|
||||
{(status === 'connecting' || status === 'error' || (status === 'disconnected' && errorMsg)) && (
|
||||
<div className={`absolute inset-x-0 bottom-0 border-t px-4 py-2 text-sm ${status === 'error' ? 'border-red-900 bg-red-950 text-red-100' : 'border-gray-800 bg-gray-950 text-gray-200'}`}>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { BrowserRouter } from 'react-router-dom'
|
||||
import App from './App'
|
||||
import { AuthProvider } from './contexts/AuthContext'
|
||||
import { ThemeProvider } from './contexts/ThemeContext'
|
||||
import { LanguageProvider } from './contexts/LanguageContext'
|
||||
import { DialogProvider } from './components/Dialog'
|
||||
import './index.css'
|
||||
|
||||
@@ -11,11 +12,13 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<DialogProvider>
|
||||
<App />
|
||||
</DialogProvider>
|
||||
</AuthProvider>
|
||||
<LanguageProvider>
|
||||
<AuthProvider>
|
||||
<DialogProvider>
|
||||
<App />
|
||||
</DialogProvider>
|
||||
</AuthProvider>
|
||||
</LanguageProvider>
|
||||
</ThemeProvider>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>,
|
||||
|
||||
Vendored
+1
-1
@@ -1,4 +1,4 @@
|
||||
declare module '@novnc/novnc' {
|
||||
declare module '@novnc/novnc/lib/rfb' {
|
||||
export default class RFB extends EventTarget {
|
||||
constructor(target: HTMLElement, url: string, options?: { credentials?: Record<string, string>; shared?: boolean; repeaterID?: string; wsProtocols?: string[] })
|
||||
scaleViewport: boolean
|
||||
|
||||
+1041
-214
File diff suppressed because it is too large
Load Diff
@@ -124,7 +124,7 @@ export default function ContainerDetail() {
|
||||
const vncFullscreenRef = useRef<HTMLDivElement>(null)
|
||||
const [vncFullscreen, setVncFullscreen] = useState(false)
|
||||
const [showNat, setShowNat] = useState(false)
|
||||
const [showNatAdd, setShowNatAdd] = useState(false)
|
||||
const [showMappingEditor, setShowMappingEditor] = useState(false)
|
||||
const [showExpiryEdit, setShowExpiryEdit] = useState(false)
|
||||
const [editExpiry, setEditExpiry] = useState('')
|
||||
const [savingExpiry, setSavingExpiry] = useState(false)
|
||||
@@ -452,10 +452,10 @@ export default function ContainerDetail() {
|
||||
const digits = '23456789'
|
||||
const symbols = '!@#$%*-_+='
|
||||
const all = letters + digits + symbols
|
||||
const pick = (chars: string) => chars[Math.floor(Math.random() * chars.length)]
|
||||
const pick = (chars: string) => chars[secureRandomInt(chars.length)]
|
||||
let password = pick(letters) + pick(digits)
|
||||
while (password.length < 16) password += pick(all)
|
||||
setResetPasswordDraft(password.split('').sort(() => Math.random() - 0.5).join(''))
|
||||
setResetPasswordDraft(secureShuffle(password.split('')).join(''))
|
||||
setResetPasswordResult('')
|
||||
}
|
||||
|
||||
@@ -517,21 +517,11 @@ export default function ContainerDetail() {
|
||||
if (isSubUser && container?.policy_blocked) return
|
||||
setDraft(emptyDraft)
|
||||
setShowNat(true)
|
||||
setShowMappingEditor(true)
|
||||
}
|
||||
|
||||
const openEditMapping = (pm: PortMapping, index: number) => {
|
||||
if (isSubUser && container?.policy_blocked) return
|
||||
if (isSubUser) {
|
||||
// Sub-user: only edit container_port in a simple modal
|
||||
setDraft({
|
||||
index,
|
||||
description: pm.description,
|
||||
host_port: String(pm.host_port),
|
||||
container_port: String(pm.container_port),
|
||||
protocol: pm.protocol || 'all',
|
||||
})
|
||||
return
|
||||
}
|
||||
setDraft({
|
||||
index,
|
||||
description: pm.description,
|
||||
@@ -539,6 +529,8 @@ export default function ContainerDetail() {
|
||||
container_port: String(pm.container_port),
|
||||
protocol: pm.protocol || 'all',
|
||||
})
|
||||
setShowNat(true)
|
||||
setShowMappingEditor(true)
|
||||
}
|
||||
|
||||
const submitMapping = async (): Promise<boolean> => {
|
||||
@@ -1394,9 +1386,9 @@ export default function ContainerDetail() {
|
||||
)}
|
||||
|
||||
{showNat && (
|
||||
<Modal title="NAT 端口管理" onClose={() => { setShowNat(false); setDraft(emptyDraft); setShowNatAdd(false) }} wide extra={
|
||||
!isSubUser && canAddMapping && !showNatAdd && (
|
||||
<button onClick={() => { setShowNatAdd(true); setDraft({...emptyDraft}) }} className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-black text-white rounded-md text-xs hover:bg-gray-800">
|
||||
<Modal title="NAT 端口管理" onClose={() => { setShowNat(false); setDraft(emptyDraft); setShowMappingEditor(false) }} wide extra={
|
||||
!isSubUser && canAddMapping && (
|
||||
<button onClick={openAddMapping} className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-black text-white rounded-md text-xs hover:bg-gray-800">
|
||||
<Plus className="w-3.5 h-3.5" />添加映射
|
||||
</button>
|
||||
)
|
||||
@@ -1411,96 +1403,33 @@ export default function ContainerDetail() {
|
||||
)}
|
||||
</div>
|
||||
<MappingTable mappings={container.port_mappings || []} publicHost={publicHost} onEdit={openEditMapping} onDelete={isSubUser ? () => {} : removeMapping} isSubUser={isSubUser} />
|
||||
{showNatAdd && !isSubUser && (
|
||||
<div className="border border-gray-200 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-semibold text-black">添加映射规则</h3>
|
||||
<button onClick={() => setShowNatAdd(false)} className="text-xs text-gray-500 hover:text-black"><X className="w-3.5 h-3.5" /></button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-5 gap-3">
|
||||
<Field label="名称">
|
||||
<input value={draft.description} onChange={(e) => setDraft({ ...draft, description: e.target.value })} className={inputClass} placeholder="Web / API" />
|
||||
</Field>
|
||||
<Field label="协议">
|
||||
<select value={draft.protocol} onChange={(e) => setDraft({ ...draft, protocol: e.target.value })} className={inputClass}>
|
||||
<option value="all">全部 (ALL)</option>
|
||||
<option value="tcp">TCP</option>
|
||||
<option value="udp">UDP</option>
|
||||
<option value="tcp+udp">TCP+UDP</option>
|
||||
<option value="icmp">ICMP</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="外部端口">
|
||||
<div className="flex gap-1">
|
||||
<input value={draft.host_port} onChange={(e) => setDraft({ ...draft, host_port: e.target.value })} className={inputClass + ' flex-1'} placeholder="默认同内部" />
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
const res = await api.get<APIResponse<{port: number}>>(`/containers/${containerIdentifier}/random-port`)
|
||||
const port = res.data.data?.port || 0
|
||||
if (port > 0) setDraft({ ...draft, host_port: String(port) })
|
||||
} catch { /* ignore */ }
|
||||
}}
|
||||
className="px-2 py-2 border border-gray-300 rounded-md text-xs text-gray-500 hover:bg-gray-50"
|
||||
title="随机空闲端口"
|
||||
>随机</button>
|
||||
</div>
|
||||
</Field>
|
||||
<Field label="内部端口">
|
||||
<input
|
||||
value={draft.container_port}
|
||||
onChange={(e) => setDraft({ ...draft, container_port: e.target.value })}
|
||||
className={inputClass}
|
||||
placeholder="例如 80"
|
||||
/>
|
||||
</Field>
|
||||
<div className="flex items-end">
|
||||
<button onClick={async () => { if (await submitMapping()) setShowNatAdd(false) }} disabled={savingMapping || !canAddMapping} className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-2 bg-black text-white rounded-md text-sm hover:bg-gray-800 disabled:opacity-50">
|
||||
<Save className="w-4 h-4" />
|
||||
{savingMapping ? '保存中...' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Sub-user edit port modal: only container_port is editable */}
|
||||
{draft.index !== null && isSubUser && (
|
||||
<div className="border border-gray-200 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-semibold text-black">修改端口映射</h3>
|
||||
<button onClick={() => setDraft(emptyDraft)} className="text-xs text-gray-500 hover:text-black"><X className="w-3.5 h-3.5" /></button>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<Field label="名称">
|
||||
<input value={draft.description} disabled className="w-full px-3 py-2 border border-gray-200 rounded-md text-sm text-gray-400 bg-gray-50" />
|
||||
</Field>
|
||||
<Field label="协议">
|
||||
<input value={draft.protocol.toUpperCase()} disabled className="w-full px-3 py-2 border border-gray-200 rounded-md text-sm text-gray-400 bg-gray-50" />
|
||||
</Field>
|
||||
<Field label="外部端口">
|
||||
<input value={draft.host_port} disabled className="w-full px-3 py-2 border border-gray-200 rounded-md text-sm text-gray-400 bg-gray-50" />
|
||||
</Field>
|
||||
<Field label="内部端口">
|
||||
<input
|
||||
value={draft.container_port}
|
||||
onChange={(e) => setDraft({ ...draft, container_port: e.target.value })}
|
||||
className={inputClass}
|
||||
placeholder="例如 80"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 mt-4">
|
||||
<button onClick={() => setDraft(emptyDraft)} className="px-4 py-2 text-sm text-gray-600 border border-gray-200 rounded-md hover:bg-gray-50">取消</button>
|
||||
<button onClick={async () => { if (await submitMapping()) setDraft(emptyDraft) }} disabled={savingMapping} className="px-4 py-2 text-sm bg-black text-white rounded-md hover:bg-gray-800 disabled:opacity-50">
|
||||
{savingMapping ? '保存中...' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{showMappingEditor && (
|
||||
<Modal
|
||||
title={draft.index === null ? '添加端口映射' : '修改端口映射'}
|
||||
onClose={() => { setShowMappingEditor(false); setDraft(emptyDraft) }}
|
||||
>
|
||||
<MappingEditor
|
||||
draft={draft}
|
||||
setDraft={setDraft}
|
||||
isSubUser={isSubUser}
|
||||
canAddMapping={canAddMapping}
|
||||
saving={savingMapping}
|
||||
containerIdentifier={containerIdentifier}
|
||||
onCancel={() => { setShowMappingEditor(false); setDraft(emptyDraft) }}
|
||||
onSubmit={async () => {
|
||||
if (await submitMapping()) {
|
||||
setShowMappingEditor(false)
|
||||
setDraft(emptyDraft)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{showSubUser && subUser && (
|
||||
<Modal title="管理链接" onClose={() => setShowSubUser(false)}>
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 text-sm space-y-3">
|
||||
@@ -1803,6 +1732,116 @@ function SnapshotTable({ snapshots, busy, onRestore, onDelete }: {
|
||||
)
|
||||
}
|
||||
|
||||
function MappingEditor({
|
||||
draft,
|
||||
setDraft,
|
||||
isSubUser,
|
||||
canAddMapping,
|
||||
saving,
|
||||
containerIdentifier,
|
||||
onCancel,
|
||||
onSubmit,
|
||||
}: {
|
||||
draft: MappingDraft
|
||||
setDraft: (draft: MappingDraft) => void
|
||||
isSubUser: boolean
|
||||
canAddMapping: boolean
|
||||
saving: boolean
|
||||
containerIdentifier: string
|
||||
onCancel: () => void
|
||||
onSubmit: () => void
|
||||
}) {
|
||||
const isEditing = draft.index !== null
|
||||
const updateDraft = (patch: Partial<MappingDraft>) => setDraft({ ...draft, ...patch })
|
||||
const disabledInputClass = 'w-full px-3 py-2 border border-gray-200 rounded-md text-sm text-gray-400 bg-gray-50'
|
||||
|
||||
const fillRandomPort = async () => {
|
||||
try {
|
||||
const res = await api.get<APIResponse<{ port: number }>>(`/containers/${containerIdentifier}/random-port`)
|
||||
const port = res.data.data?.port || 0
|
||||
if (port > 0) updateDraft({ host_port: String(port) })
|
||||
} catch {
|
||||
// keep manual input available if random port lookup fails
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<Field label="名称">
|
||||
<input
|
||||
value={draft.description}
|
||||
disabled={isSubUser}
|
||||
onChange={(e) => updateDraft({ description: e.target.value })}
|
||||
className={isSubUser ? disabledInputClass : inputClass}
|
||||
placeholder="Web / API"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="协议">
|
||||
{isSubUser ? (
|
||||
<input value={draft.protocol.toUpperCase()} disabled className={disabledInputClass} />
|
||||
) : (
|
||||
<select value={draft.protocol} onChange={(e) => updateDraft({ protocol: e.target.value })} className={inputClass}>
|
||||
<option value="all">全部 (ALL)</option>
|
||||
<option value="tcp">TCP</option>
|
||||
<option value="udp">UDP</option>
|
||||
<option value="tcp+udp">TCP+UDP</option>
|
||||
<option value="icmp">ICMP</option>
|
||||
</select>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Field label="外部端口">
|
||||
{isSubUser ? (
|
||||
<input value={draft.host_port} disabled className={disabledInputClass} />
|
||||
) : (
|
||||
<div className="flex gap-1">
|
||||
<input
|
||||
value={draft.host_port}
|
||||
onChange={(e) => updateDraft({ host_port: e.target.value })}
|
||||
className={`${inputClass} flex-1`}
|
||||
placeholder="默认同内部"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={fillRandomPort}
|
||||
className="rounded-md border border-gray-300 px-2 py-2 text-xs text-gray-500 hover:bg-gray-50"
|
||||
title="随机空闲端口"
|
||||
>
|
||||
随机
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Field label="内部端口">
|
||||
<input
|
||||
value={draft.container_port}
|
||||
onChange={(e) => updateDraft({ container_port: e.target.value })}
|
||||
className={inputClass}
|
||||
placeholder="例如 80"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 border-t border-gray-100 pt-4">
|
||||
<button onClick={onCancel} className="px-4 py-2 text-sm text-gray-600 border border-gray-200 rounded-md hover:bg-gray-50">
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={onSubmit}
|
||||
disabled={saving || (!isEditing && !canAddMapping)}
|
||||
className="inline-flex items-center justify-center gap-1.5 px-4 py-2 text-sm bg-black text-white rounded-md hover:bg-gray-800 disabled:opacity-50"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
{saving ? '保存中...' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MappingTable({ mappings, publicHost, onEdit, onDelete, compact = false, isSubUser = false }: { mappings: PortMapping[]; publicHost: string; onEdit: (pm: PortMapping, index: number) => void; onDelete: (index: number) => void; compact?: boolean; isSubUser?: boolean }) {
|
||||
if (mappings.length === 0) {
|
||||
return <p className="text-sm text-gray-400">暂无端口映射</p>
|
||||
@@ -2054,6 +2093,32 @@ function TrafficBar({ container }: { container: Container }) {
|
||||
)
|
||||
}
|
||||
|
||||
function secureRandomInt(maxExclusive: number) {
|
||||
if (!Number.isSafeInteger(maxExclusive) || maxExclusive <= 0) {
|
||||
throw new Error('invalid random range')
|
||||
}
|
||||
const values = new Uint32Array(1)
|
||||
const maxUint32 = 0x100000000
|
||||
const limit = Math.floor(maxUint32 / maxExclusive) * maxExclusive
|
||||
let value = 0
|
||||
do {
|
||||
crypto.getRandomValues(values)
|
||||
value = values[0]
|
||||
} while (value >= limit)
|
||||
return value % maxExclusive
|
||||
}
|
||||
|
||||
function secureShuffle<T>(items: T[]) {
|
||||
const next = [...items]
|
||||
for (let i = next.length - 1; i > 0; i--) {
|
||||
const j = secureRandomInt(i + 1)
|
||||
const value = next[i]
|
||||
next[i] = next[j]
|
||||
next[j] = value
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
function getTemplateIcon(id: string): ReactNode {
|
||||
const size = 'w-6 h-6'
|
||||
id = id.startsWith('kvm-') ? id.slice(4) : id
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
import { ReactNode, useCallback, useEffect, useState } from 'react'
|
||||
import {
|
||||
Activity,
|
||||
CheckCircle2,
|
||||
Cpu,
|
||||
HardDrive,
|
||||
MemoryStick,
|
||||
RefreshCw,
|
||||
XCircle,
|
||||
} from 'lucide-react'
|
||||
import { getHostReport, HostProbeReport } from '../services/api'
|
||||
|
||||
export default function HostReport() {
|
||||
const [report, setReport] = useState<HostProbeReport | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const fetchReport = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await getHostReport()
|
||||
setReport(res.data.data || null)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchReport()
|
||||
}, [fetchReport])
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-black">宿主机信息</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">硬件、网络、磁盘健康与运行环境探测报告</p>
|
||||
</div>
|
||||
<button onClick={fetchReport} disabled={loading} className="inline-flex items-center gap-1.5 rounded-md border border-gray-200 px-3 py-2 text-sm text-gray-600 hover:bg-gray-50 disabled:opacity-50">
|
||||
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading && !report ? (
|
||||
<div className="rounded-lg border border-gray-200 bg-white py-14 text-center text-sm text-gray-400">正在探测宿主机环境...</div>
|
||||
) : !report ? (
|
||||
<div className="rounded-lg border border-gray-200 bg-white py-14 text-center text-sm text-gray-400">暂未获取到宿主机信息</div>
|
||||
) : (
|
||||
<div className="space-y-5">
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
<ProbeMetric icon={<Cpu className="h-4 w-4" />} label="CPU" value={report.cpu.model || 'Unknown'} sub={`${report.cpu.cores} 核 / ${report.cpu.threads} 线程`} />
|
||||
<ProbeMetric icon={<MemoryStick className="h-4 w-4" />} label="RAM" value={formatMB(report.memory.total_mb)} sub={`${formatMB(report.memory.used_mb)} 已用`} />
|
||||
<ProbeMetric icon={<HardDrive className="h-4 w-4" />} label="DISK" value={`${report.disks.length} 块硬盘`} sub={report.disks.map(d => d.type).filter(Boolean).join(' / ') || 'Unknown'} />
|
||||
<ProbeMetric icon={<Activity className="h-4 w-4" />} label="运行状态" value={report.system.uptime_text} sub={`${report.system.process_count} 个进程`} />
|
||||
</div>
|
||||
|
||||
<ProbeSection title="系统概览">
|
||||
<ProbeRows rows={[
|
||||
['主机名', report.hostname],
|
||||
['操作系统', report.os],
|
||||
['内核', report.kernel],
|
||||
['生成时间', report.generated_at],
|
||||
['CPU 架构', report.cpu.architecture],
|
||||
['CPU 虚拟化指令', report.cpu.virtualization ? `支持 (${report.cpu.virtualization_key})` : '未检测到'],
|
||||
['CPU 核显', report.cpu.has_integrated_gpu ? '检测到' : '未检测到'],
|
||||
['显卡', report.gpus.length ? `${report.gpus.length} 个` : '未检测到'],
|
||||
['运行能力', runtimeModeLabel(report.runtime.support_mode)],
|
||||
['KVM 嵌套虚拟化', `${report.runtime.nested_virtualization ? '支持' : '未检测到'} (${report.runtime.nested_detail || '-'})`],
|
||||
]} />
|
||||
</ProbeSection>
|
||||
|
||||
<ProbeSection title="公网与路由">
|
||||
<ProbeRows rows={[
|
||||
['公网 IPv4', report.public_ipv4.length ? report.public_ipv4.join('\n') : '未检测到'],
|
||||
['IPv4 地址', report.ipv4_addresses?.length ? report.ipv4_addresses.map(formatIPv4Address).join('\n') : '未检测到'],
|
||||
['IPv4 段', report.ipv4_prefixes?.length ? report.ipv4_prefixes.map(formatIPv4Prefix).join('\n') : '未检测到'],
|
||||
['IPv6 地址', report.ipv6_addresses.length ? report.ipv6_addresses.map(ip => `${ip.address}/${ip.prefix_len} (${ip.interface})`).join('\n') : '未检测到'],
|
||||
['IPv6 段', report.ipv6_prefixes?.length ? report.ipv6_prefixes.map(formatIPv6Prefix).join('\n') : '未检测到'],
|
||||
['网关', report.gateways.length ? report.gateways.map(g => `${g.family}: ${g.gateway || '-'} dev ${g.interface || '-'}`).join('\n') : '未检测到'],
|
||||
]} />
|
||||
</ProbeSection>
|
||||
|
||||
<ProbeTable
|
||||
title="内存条"
|
||||
empty="未检测到内存条明细,可能缺少 dmidecode 或权限受限"
|
||||
headers={['插槽', '容量', '类型', '频率', '厂商', '型号/序列号']}
|
||||
rows={(report.memory.modules || []).map(m => [
|
||||
m.locator || '-',
|
||||
m.size || '-',
|
||||
m.type || '-',
|
||||
m.speed || '-',
|
||||
m.manufacturer || '-',
|
||||
[m.part_number, m.serial_number].filter(Boolean).join(' / ') || '-',
|
||||
])}
|
||||
/>
|
||||
|
||||
<ProbeTable
|
||||
title="硬盘与健康"
|
||||
empty="未检测到硬盘"
|
||||
headers={['设备', '型号', '容量', '类型', '挂载点', '健康', '寿命', '通电', '读取', '写入', '命令数', '擦写']}
|
||||
rows={report.disks.map(d => [
|
||||
`${d.path || d.name}\n${d.serial || ''}`,
|
||||
d.model || '-',
|
||||
formatBytes(d.size_bytes),
|
||||
d.type || (d.rotational ? 'HDD' : 'SSD'),
|
||||
d.mountpoints?.length ? d.mountpoints.join('\n') : '-',
|
||||
`${diskHealthLabel(d.health)}\n${d.health_detail || ''}`,
|
||||
formatLifeUsed(d.smart?.life_used_percent),
|
||||
d.smart?.power_on_hours ? `${d.smart.power_on_hours} 小时\n${formatPowerOnDays(d.smart.power_on_hours)}` : '-',
|
||||
formatBytes(d.smart?.read_data_bytes || 0),
|
||||
formatBytes(d.smart?.written_data_bytes || 0),
|
||||
formatCommands(d.smart?.read_commands, d.smart?.write_commands),
|
||||
formatWear(d.smart?.wear_leveling_count, d.smart?.erase_count, d.smart?.power_cycle_count),
|
||||
])}
|
||||
/>
|
||||
|
||||
<ProbeTable
|
||||
title="网卡"
|
||||
empty="未检测到网卡"
|
||||
headers={['网卡', '状态', '驱动/速率', 'MAC', 'IPv4', 'IPv6']}
|
||||
rows={report.network_interfaces.map(n => [
|
||||
`${n.name}\n${n.model || ''}`,
|
||||
n.state || '-',
|
||||
`${n.driver || '-'}\n${n.speed_mbps > 0 ? `${n.speed_mbps} Mbps` : '-'}`,
|
||||
n.mac || '-',
|
||||
n.ipv4?.length ? n.ipv4.map(ip => `${ip.address}/${ip.prefix_len}`).join('\n') : '-',
|
||||
n.ipv6?.length ? n.ipv6.map(ip => `${ip.address}/${ip.prefix_len} ${ip.scope}`).join('\n') : '-',
|
||||
])}
|
||||
/>
|
||||
|
||||
<ProbeTable
|
||||
title="显卡"
|
||||
empty="未检测到显卡"
|
||||
headers={['名称', '厂商', '类型', '驱动']}
|
||||
rows={report.gpus.map(g => [g.name, g.vendor || '-', gpuTypeLabel(g.type), g.driver || '-'])}
|
||||
/>
|
||||
|
||||
<ProbeSection title="环境支持">
|
||||
<div className="grid gap-2 md:grid-cols-2">
|
||||
{report.environment.map(item => (
|
||||
<div key={item.key} className="flex items-start gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2">
|
||||
{item.ok ? <CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0 text-green-600" /> : <XCircle className={`mt-0.5 h-4 w-4 shrink-0 ${item.required ? 'text-red-600' : 'text-amber-600'}`} />}
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs font-medium text-gray-800">
|
||||
<span>{item.label}</span>
|
||||
<span className={`rounded px-1.5 py-0.5 text-[10px] ${item.required ? 'bg-gray-100 text-gray-600' : 'bg-blue-50 text-blue-700'}`}>
|
||||
{item.required ? '必要' : '可选'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 break-all font-mono text-[11px] text-gray-500">{item.detail || '-'}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ProbeSection>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ProbeMetric({ icon, label, value, sub }: { icon: ReactNode; label: string; value: string; sub: string }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-gray-200 bg-white px-3 py-3">
|
||||
<div className="mb-2 flex items-center gap-2 text-xs font-medium text-gray-500">
|
||||
{icon}
|
||||
{label}
|
||||
</div>
|
||||
<div className="line-clamp-2 break-words text-sm font-semibold text-gray-900" title={value}>{value}</div>
|
||||
<div className="mt-1 truncate text-xs text-gray-500" title={sub}>{sub}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ProbeSection({ title, children }: { title: string; children: ReactNode }) {
|
||||
return (
|
||||
<section>
|
||||
<h2 className="mb-2 text-sm font-semibold text-black">{title}</h2>
|
||||
{children}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function ProbeRows({ rows }: { rows: Array<[string, string]> }) {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-lg border border-gray-200 bg-white">
|
||||
{rows.map(([label, value]) => (
|
||||
<div key={label} className="grid gap-2 border-b border-gray-100 px-3 py-2 text-xs last:border-b-0 md:grid-cols-[160px_1fr]">
|
||||
<div className="font-medium text-gray-500">{label}</div>
|
||||
<div className="whitespace-pre-wrap break-words font-mono text-gray-800">{value || '-'}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ProbeTable({ title, headers, rows, empty }: { title: string; headers: string[]; rows: string[][]; empty: string }) {
|
||||
return (
|
||||
<section>
|
||||
<h2 className="mb-2 text-sm font-semibold text-black">{title}</h2>
|
||||
{rows.length === 0 ? (
|
||||
<div className="rounded-lg border border-gray-200 bg-white px-3 py-3 text-xs text-gray-400">{empty}</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-lg border border-gray-200 bg-white">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-100 bg-gray-50 text-left text-gray-500">
|
||||
{headers.map(header => <th key={header} className="px-3 py-2 font-medium">{header}</th>)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{rows.map((row, rowIndex) => (
|
||||
<tr key={rowIndex} className="align-top">
|
||||
{row.map((cell, cellIndex) => (
|
||||
<td key={cellIndex} className="max-w-[280px] whitespace-pre-wrap break-words px-3 py-2 text-gray-700">
|
||||
{cell || '-'}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function formatIPv4Address(ip: HostProbeReport['ipv4_addresses'][number]) {
|
||||
return `${ip.address}/${ip.prefix_len} (${ip.interface})`
|
||||
}
|
||||
|
||||
function formatIPv4Prefix(prefix: HostProbeReport['ipv4_prefixes'][number]) {
|
||||
const parts = [
|
||||
prefix.prefix || '-',
|
||||
prefix.subnet_mask ? `mask ${prefix.subnet_mask}` : '',
|
||||
prefix.gateway ? `via ${prefix.gateway}` : '',
|
||||
prefix.interface ? `dev ${prefix.interface}` : '',
|
||||
prefix.source ? `[${prefix.source}]` : '',
|
||||
].filter(Boolean)
|
||||
return parts.join(' ')
|
||||
}
|
||||
|
||||
function formatIPv6Prefix(prefix: HostProbeReport['ipv6_prefixes'][number]) {
|
||||
const value = prefix.prefix || prefix.address || '-'
|
||||
const cidr = value.includes('/') || !prefix.prefix_len ? value : `${value}/${prefix.prefix_len}`
|
||||
return `${cidr} via ${prefix.gateway || '-'}`
|
||||
}
|
||||
|
||||
function formatMB(value: number) {
|
||||
if (!value) return '-'
|
||||
if (value >= 1024) return `${(value / 1024).toFixed(1)} GB`
|
||||
return `${value} MB`
|
||||
}
|
||||
|
||||
function formatBytes(value: number) {
|
||||
if (!value) return '-'
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']
|
||||
let next = value
|
||||
let index = 0
|
||||
while (next >= 1024 && index < units.length - 1) {
|
||||
next /= 1024
|
||||
index++
|
||||
}
|
||||
return `${next.toFixed(index === 0 ? 0 : 1)} ${units[index]}`
|
||||
}
|
||||
|
||||
function formatLifeUsed(value?: number) {
|
||||
if (value === undefined || value === null) return '-'
|
||||
return `${value}% 已用\n${Math.max(0, 100 - value)}% 剩余`
|
||||
}
|
||||
|
||||
function formatPowerOnDays(hours: number) {
|
||||
const days = Math.floor(hours / 24)
|
||||
const rest = hours % 24
|
||||
return days > 0 ? `${days} 天 ${rest} 小时` : `${hours} 小时`
|
||||
}
|
||||
|
||||
function formatCommands(read?: number, write?: number) {
|
||||
if (!read && !write) return '-'
|
||||
return `读 ${formatCount(read || 0)}\n写 ${formatCount(write || 0)}`
|
||||
}
|
||||
|
||||
function formatCount(value: number) {
|
||||
if (!value) return '-'
|
||||
if (value >= 1_000_000_000) return `${(value / 1_000_000_000).toFixed(1)}B`
|
||||
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`
|
||||
if (value >= 1_000) return `${(value / 1_000).toFixed(1)}K`
|
||||
return `${value}`
|
||||
}
|
||||
|
||||
function formatWear(wear?: string, erase?: string, powerCycles?: number) {
|
||||
const rows: string[] = []
|
||||
if (wear) rows.push(`磨损 ${wear}`)
|
||||
if (erase) rows.push(`擦写 ${erase}`)
|
||||
if (powerCycles) rows.push(`启停 ${powerCycles}`)
|
||||
return rows.length ? rows.join('\n') : '-'
|
||||
}
|
||||
|
||||
function runtimeModeLabel(value: string) {
|
||||
switch (value) {
|
||||
case 'kvm_lxc':
|
||||
return '支持 KVM + LXC'
|
||||
case 'lxc_only':
|
||||
return '仅支持 LXC'
|
||||
default:
|
||||
return '未满足运行环境'
|
||||
}
|
||||
}
|
||||
|
||||
function diskHealthLabel(value: string) {
|
||||
switch (value) {
|
||||
case 'ok':
|
||||
return '健康'
|
||||
case 'failed':
|
||||
return '异常'
|
||||
default:
|
||||
return '未知'
|
||||
}
|
||||
}
|
||||
|
||||
function gpuTypeLabel(value: string) {
|
||||
if (value === 'integrated') return '核显'
|
||||
if (value === 'discrete') return '独显'
|
||||
return value || '-'
|
||||
}
|
||||
@@ -2,9 +2,21 @@ import { FormEvent, useState } from 'react'
|
||||
import { Lock, User } from 'lucide-react'
|
||||
import AppIcon from '../components/AppIcon'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { useLanguage } from '../contexts/LanguageContext'
|
||||
import AutoTranslate from '../components/AutoTranslate'
|
||||
import BrowserDialogTranslator from '../components/BrowserDialogTranslator'
|
||||
|
||||
function LanguageIcon({ className = '' }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<path d="M213.333333 640v85.333333a85.333333 85.333333 0 0 0 78.933334 85.12L298.666667 810.666667h128v85.333333H298.666667a170.666667 170.666667 0 0 1-170.666667-170.666667v-85.333333h85.333333z m554.666667-213.333333l187.733333 469.333333h-91.946666l-51.242667-128h-174.506667l-51.157333 128h-91.904L682.666667 426.666667h85.333333z m-42.666667 123.093333L672.128 682.666667h106.325333L725.333333 549.76zM341.333333 85.333333v85.333334h170.666667v298.666666H341.333333v128H256v-128H85.333333V170.666667h170.666667V85.333333h85.333333z m384 42.666667a170.666667 170.666667 0 0 1 170.666667 170.666667v85.333333h-85.333333V298.666667a85.333333 85.333333 0 0 0-85.333334-85.333334h-128V128h128zM256 256H170.666667v128h85.333333V256z m170.666667 0H341.333333v128h85.333334V256z" fill="currentColor" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Login() {
|
||||
const { login, accessCodeLogin } = useAuth()
|
||||
const { language, toggleLanguage, t } = useLanguage()
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
@@ -29,7 +41,7 @@ export default function Login() {
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { message?: string } } }
|
||||
setError(error.response?.data?.message || '登录失败,请检查用户名和密码')
|
||||
setError(error.response?.data?.message || t('登录失败,请检查用户名和密码'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -37,6 +49,16 @@ export default function Login() {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
|
||||
<AutoTranslate />
|
||||
<BrowserDialogTranslator />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { void toggleLanguage() }}
|
||||
className="absolute right-4 top-4 inline-flex items-center gap-1.5 rounded-md border border-gray-200 bg-white px-3 py-1.5 text-xs font-medium text-gray-600 shadow-sm hover:bg-gray-50"
|
||||
>
|
||||
<LanguageIcon className="h-3.5 w-3.5" />
|
||||
{language === 'en' ? '中文' : 'English'}
|
||||
</button>
|
||||
<div className="w-full max-w-md">
|
||||
<div className="bg-white rounded-lg border border-gray-200 shadow-sm p-8">
|
||||
<div className="flex flex-col items-center mb-8">
|
||||
@@ -106,7 +128,7 @@ export default function Login() {
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-xs text-gray-400 mt-6">CLICD v1.1.2</p>
|
||||
<p className="text-center text-xs text-gray-400 mt-6">CLICD v1.1.9</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
+332
-98
@@ -1,10 +1,13 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { UserCog, Key, LogIn, Monitor, Clock, Globe } from 'lucide-react'
|
||||
import { Dispatch, SetStateAction, useCallback, useEffect, useState } from 'react'
|
||||
import { Clock, Globe, Lock, LogIn, Monitor, RefreshCw, ShieldCheck, Upload, UserCog } from 'lucide-react'
|
||||
import {
|
||||
changePassword,
|
||||
changeUsername,
|
||||
getLoginLogs,
|
||||
getSSLSettings,
|
||||
LoginLog,
|
||||
SSLSettings,
|
||||
updateSSLSettings,
|
||||
} from '../services/api'
|
||||
import { useDialog } from '../components/Dialog'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
@@ -20,7 +23,16 @@ export default function Settings() {
|
||||
const [oldPwd, setOldPwd] = useState('')
|
||||
const [newPwd, setNewPwd] = useState('')
|
||||
const [newUsername, setNewUsername] = useState('')
|
||||
const [pwdForUser, setPwdForUser] = useState('')
|
||||
|
||||
const [ssl, setSSL] = useState<SSLSettings | null>(null)
|
||||
const [sslEnabled, setSSLEnabled] = useState(false)
|
||||
const [sslMode, setSSLMode] = useState<SSLSettings['mode']>('disabled')
|
||||
const [sslTarget, setSSLTarget] = useState('')
|
||||
const [sslEmail, setSSLEmail] = useState('')
|
||||
const [certPEM, setCertPEM] = useState('')
|
||||
const [keyPEM, setKeyPEM] = useState('')
|
||||
const [applyNow, setApplyNow] = useState(true)
|
||||
const [savingSSL, setSavingSSL] = useState(false)
|
||||
|
||||
const fetchLogs = useCallback(async () => {
|
||||
try {
|
||||
@@ -33,30 +45,95 @@ export default function Settings() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { fetchLogs(); const t = setInterval(fetchLogs, 15000); return () => clearInterval(t) }, [fetchLogs])
|
||||
const fetchSSL = useCallback(async () => {
|
||||
try {
|
||||
const res = await getSSLSettings()
|
||||
const data = res.data.data
|
||||
if (!data) return
|
||||
setSSL(data)
|
||||
setSSLEnabled(data.enabled)
|
||||
setSSLMode(data.mode || 'disabled')
|
||||
setSSLTarget(data.target || data.detected_host || '')
|
||||
setSSLEmail(data.email || '')
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchLogs()
|
||||
fetchSSL()
|
||||
const timer = setInterval(fetchLogs, 15000)
|
||||
return () => clearInterval(timer)
|
||||
}, [fetchLogs, fetchSSL])
|
||||
|
||||
const handleSSLModeChange = (mode: SSLSettings['mode']) => {
|
||||
setSSLMode(mode)
|
||||
const saved = ssl?.mode_certificates?.[mode]
|
||||
setSSLTarget(saved?.target || ssl?.detected_host || sslTarget)
|
||||
setSSLEmail(saved?.email || '')
|
||||
}
|
||||
|
||||
const handleSaveSSL = async () => {
|
||||
setSavingSSL(true)
|
||||
try {
|
||||
const enabled = sslEnabled && sslMode !== 'disabled'
|
||||
const res = await updateSSLSettings({
|
||||
enabled,
|
||||
mode: enabled ? sslMode : 'disabled',
|
||||
target: sslTarget,
|
||||
email: sslEmail,
|
||||
cert_pem: certPEM,
|
||||
key_pem: keyPEM,
|
||||
apply_now: applyNow,
|
||||
})
|
||||
if (res.data.data) {
|
||||
setSSL(res.data.data)
|
||||
setCertPEM('')
|
||||
setKeyPEM('')
|
||||
}
|
||||
dialog.alert('完成', applyNow ? 'SSL 设置已保存,服务正在重启。稍后请用新的协议重新打开面板。' : 'SSL 设置已保存,重启 clicd 服务后生效。')
|
||||
} catch (err: unknown) {
|
||||
const e = err as { response?: { data?: { message?: string } } }
|
||||
dialog.alert('失败', e.response?.data?.message || 'SSL 设置保存失败')
|
||||
} finally {
|
||||
setSavingSSL(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveAccount = async () => {
|
||||
if (!oldPwd) { dialog.alert('提示', '请输入当前密码以确认修改'); return }
|
||||
if (!newPwd && !newUsername) { dialog.alert('提示', '至少填写新密码或新用户名中的一项'); return }
|
||||
if (newPwd && newPwd.length < 6) { dialog.alert('提示', '新密码至少 6 位'); return }
|
||||
if (newUsername && newUsername.length < 3) { dialog.alert('提示', '用户名至少 3 位'); return }
|
||||
if (!oldPwd) {
|
||||
dialog.alert('提示', '请输入当前密码以确认修改')
|
||||
return
|
||||
}
|
||||
if (!newPwd && !newUsername) {
|
||||
dialog.alert('提示', '至少填写新密码或新用户名中的一项')
|
||||
return
|
||||
}
|
||||
if (newPwd && newPwd.length < 6) {
|
||||
dialog.alert('提示', '新密码至少 6 位')
|
||||
return
|
||||
}
|
||||
if (newUsername && newUsername.length < 3) {
|
||||
dialog.alert('提示', '用户名至少 3 位')
|
||||
return
|
||||
}
|
||||
|
||||
let results: string[] = []
|
||||
const results: string[] = []
|
||||
try {
|
||||
// 先改用户名(用旧密码验证),再改密码,否则改完密码后旧密码就失效了
|
||||
if (newUsername) {
|
||||
const res = await changeUsername(newUsername, oldPwd)
|
||||
if (res.data.success) results.push('用户名已修改')
|
||||
else results.push('用户名修改失败')
|
||||
results.push(res.data.success ? '用户名已修改' : '用户名修改失败')
|
||||
}
|
||||
if (newPwd) {
|
||||
const res = await changePassword(oldPwd, newPwd)
|
||||
if (res.data.success) results.push('密码已修改')
|
||||
else results.push('密码修改失败')
|
||||
results.push(res.data.success ? '密码已修改' : '密码修改失败')
|
||||
}
|
||||
if (results.length > 0) {
|
||||
dialog.alert('完成', results.join(',') + '。下次登录生效')
|
||||
setOldPwd(''); setNewPwd(''); setNewUsername('')
|
||||
dialog.alert('完成', `${results.join(',')}。下次登录生效`)
|
||||
setOldPwd('')
|
||||
setNewPwd('')
|
||||
setNewUsername('')
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const e = err as { response?: { data?: { message?: string } } }
|
||||
@@ -67,111 +144,268 @@ export default function Settings() {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-black"></div>
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-b-2 border-black"></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(logs.length / pageSize)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-black">面板设置</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">账号管理与登录日志</p>
|
||||
<p className="mt-1 text-sm text-gray-500">账号、安全证书与登录日志</p>
|
||||
</div>
|
||||
|
||||
{/* Account Settings */}
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-5">
|
||||
<h2 className="text-sm font-semibold text-black mb-4 flex items-center gap-2">
|
||||
<UserCog className="w-4 h-4" />账号设置
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">当前用户名</label>
|
||||
<input type="text" value={username || ''} disabled className="w-full px-3 py-2 border border-gray-200 rounded-md text-sm text-gray-400 bg-gray-50" />
|
||||
<div className="grid items-start gap-6 xl:grid-cols-[minmax(0,1.15fr)_minmax(360px,0.85fr)]">
|
||||
<SSLCard
|
||||
ssl={ssl}
|
||||
sslEnabled={sslEnabled}
|
||||
sslMode={sslMode}
|
||||
sslTarget={sslTarget}
|
||||
sslEmail={sslEmail}
|
||||
certPEM={certPEM}
|
||||
keyPEM={keyPEM}
|
||||
applyNow={applyNow}
|
||||
savingSSL={savingSSL}
|
||||
onRefresh={fetchSSL}
|
||||
onEnabledChange={setSSLEnabled}
|
||||
onModeChange={handleSSLModeChange}
|
||||
onTargetChange={setSSLTarget}
|
||||
onEmailChange={setSSLEmail}
|
||||
onCertChange={setCertPEM}
|
||||
onKeyChange={setKeyPEM}
|
||||
onApplyNowChange={setApplyNow}
|
||||
onSave={handleSaveSSL}
|
||||
/>
|
||||
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-5">
|
||||
<h2 className="mb-4 flex items-center gap-2 text-sm font-semibold text-black">
|
||||
<UserCog className="h-4 w-4" />账号设置
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-gray-500">当前用户名</label>
|
||||
<input type="text" value={username || ''} disabled className="w-full rounded-md border border-gray-200 bg-gray-50 px-3 py-2 text-sm text-gray-400" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-gray-500">新用户名,留空则不修改</label>
|
||||
<input type="text" value={newUsername} onChange={(e) => setNewUsername(e.target.value)} className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black" placeholder="至少 3 位" />
|
||||
</div>
|
||||
<div className="border-t border-gray-100 pt-3">
|
||||
<label className="mb-1 block text-xs text-gray-500">新密码,留空则不修改</label>
|
||||
<input type="password" value={newPwd} onChange={(e) => setNewPwd(e.target.value)} className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black" placeholder="至少 6 位" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-gray-500">当前密码,验证身份</label>
|
||||
<input type="password" value={oldPwd} onChange={(e) => setOldPwd(e.target.value)} className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black" placeholder="输入当前密码以确认修改" />
|
||||
</div>
|
||||
<button onClick={handleSaveAccount} className="w-full rounded-md bg-black px-4 py-2 text-sm text-white hover:bg-gray-800">保存修改</button>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">新用户名(留空则不修改)</label>
|
||||
<input type="text" value={newUsername} onChange={(e) => setNewUsername(e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm text-black bg-white" placeholder="至少 3 位" />
|
||||
</div>
|
||||
<div className="border-t border-gray-100 pt-3">
|
||||
<label className="block text-xs text-gray-500 mb-1">新密码(留空则不修改)</label>
|
||||
<input type="password" value={newPwd} onChange={(e) => setNewPwd(e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm text-black bg-white" placeholder="至少 6 位" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">当前密码(验证身份)</label>
|
||||
<input type="password" value={oldPwd} onChange={(e) => setOldPwd(e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm text-black bg-white" placeholder="输入当前密码以确认修改" />
|
||||
</div>
|
||||
<button onClick={handleSaveAccount} className="w-full px-4 py-2 bg-black text-white rounded-md text-sm hover:bg-gray-800">保存修改</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Login Logs */}
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-5">
|
||||
<h2 className="text-sm font-semibold text-black mb-4 flex items-center gap-2">
|
||||
<LogIn className="w-4 h-4" />登录日志
|
||||
<LoginLogCard logs={logs} logPage={logPage} pageSize={pageSize} totalPages={totalPages} setLogPage={setLogPage} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface SSLCardProps {
|
||||
ssl: SSLSettings | null
|
||||
sslEnabled: boolean
|
||||
sslMode: SSLSettings['mode']
|
||||
sslTarget: string
|
||||
sslEmail: string
|
||||
certPEM: string
|
||||
keyPEM: string
|
||||
applyNow: boolean
|
||||
savingSSL: boolean
|
||||
onRefresh: () => void
|
||||
onEnabledChange: (enabled: boolean) => void
|
||||
onModeChange: (mode: SSLSettings['mode']) => void
|
||||
onTargetChange: (target: string) => void
|
||||
onEmailChange: (email: string) => void
|
||||
onCertChange: (cert: string) => void
|
||||
onKeyChange: (key: string) => void
|
||||
onApplyNowChange: (apply: boolean) => void
|
||||
onSave: () => void
|
||||
}
|
||||
|
||||
function SSLCard(props: SSLCardProps) {
|
||||
const selectedSSL = props.ssl?.mode_certificates?.[props.sslMode]
|
||||
const modeOptions: Array<{ value: SSLSettings['mode']; label: string }> = [
|
||||
{ value: 'letsencrypt', label: 'Let’s Encrypt' },
|
||||
{ value: 'self_signed', label: '自签证书' },
|
||||
{ value: 'uploaded', label: '上传证书' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-5">
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<h2 className="flex items-center gap-2 text-sm font-semibold text-black">
|
||||
<ShieldCheck className="h-4 w-4" />SSL 证书
|
||||
</h2>
|
||||
{logs.length === 0 ? (
|
||||
<p className="text-sm text-gray-400">暂无登录记录</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="text-gray-400 border-b border-gray-100">
|
||||
<th className="text-left py-2 font-medium w-40"><span className="inline-flex items-center gap-1"><Clock className="w-3 h-3" />时间</span></th>
|
||||
<th className="text-left py-2 font-medium">用户名</th>
|
||||
<th className="text-left py-2 font-medium"><span className="inline-flex items-center gap-1"><Globe className="w-3 h-3" />IP</span></th>
|
||||
<th className="text-left py-2 font-medium"><span className="inline-flex items-center gap-1"><Monitor className="w-3 h-3" />设备</span></th>
|
||||
<th className="text-left py-2 font-medium">结果</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-50">
|
||||
{logs.slice((logPage - 1) * pageSize, logPage * pageSize).map((log, i) => (
|
||||
<tr key={i}>
|
||||
<td className="py-1.5 text-gray-500 font-mono whitespace-nowrap">{log.time}</td>
|
||||
<td className="py-1.5 text-gray-700">{log.username}</td>
|
||||
<td className="py-1.5 text-gray-500 font-mono">{log.ip}</td>
|
||||
<td className="py-1.5 text-gray-500 max-w-[180px] truncate" title={log.user_agent}>{formatUA(log.user_agent)}</td>
|
||||
<td className="py-1.5">
|
||||
<span className={`px-1.5 py-0.5 rounded text-xs ${log.success ? 'bg-gray-100 text-gray-700' : 'bg-red-50 text-red-600'}`}>
|
||||
{log.success ? '成功' : '失败'}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<button onClick={props.onRefresh} className="rounded-md border border-gray-200 p-1.5 text-gray-500 hover:bg-gray-50" title="刷新">
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<label className="flex items-center gap-2 text-sm text-gray-700">
|
||||
<input type="checkbox" checked={props.sslEnabled} onChange={(e) => props.onEnabledChange(e.target.checked)} className="h-4 w-4 rounded border-gray-300" />
|
||||
启用 HTTPS / WSS
|
||||
</label>
|
||||
|
||||
<div className="grid gap-2 sm:grid-cols-3">
|
||||
{modeOptions.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
onClick={() => props.onModeChange(option.value)}
|
||||
className={`rounded-md border px-3 py-2 text-sm ${props.sslMode === option.value ? 'border-black bg-black text-white' : 'border-gray-200 text-gray-700 hover:bg-gray-50'}`}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-gray-500">IP / 域名</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.sslTarget}
|
||||
onChange={(e) => props.onTargetChange(e.target.value)}
|
||||
className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black"
|
||||
placeholder={props.ssl?.detected_host || '服务器公网 IP 或域名'}
|
||||
/>
|
||||
</div>
|
||||
{props.sslMode === 'letsencrypt' && (
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-gray-500">邮箱,可选</label>
|
||||
<input type="email" value={props.sslEmail} onChange={(e) => props.onEmailChange(e.target.value)} className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black" placeholder="admin@example.com" />
|
||||
</div>
|
||||
{logs.length > pageSize && (
|
||||
<div className="flex items-center justify-between mt-3 pt-3 border-t border-gray-100">
|
||||
<span className="text-xs text-gray-400">共 {logs.length} 条,第 {logPage}/{Math.ceil(logs.length / pageSize)} 页</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<button onClick={() => setLogPage(1)} disabled={logPage === 1} className="px-2 py-1 text-xs border border-gray-200 rounded hover:bg-gray-50 disabled:opacity-30">首页</button>
|
||||
<button onClick={() => setLogPage(p => Math.max(1, p - 1))} disabled={logPage === 1} className="px-2 py-1 text-xs border border-gray-200 rounded hover:bg-gray-50 disabled:opacity-30">上一页</button>
|
||||
{Array.from({length: Math.min(5, Math.ceil(logs.length / pageSize))}, (_, i) => {
|
||||
const totalPages = Math.ceil(logs.length / pageSize)
|
||||
let start = Math.max(1, logPage - 2)
|
||||
if (start + 4 > totalPages) start = Math.max(1, totalPages - 4)
|
||||
const page = start + i
|
||||
if (page > totalPages) return null
|
||||
return (
|
||||
<button key={page} onClick={() => setLogPage(page)} className={`w-7 h-7 text-xs rounded ${page === logPage ? 'bg-black text-white' : 'border border-gray-200 hover:bg-gray-50'}`}>{page}</button>
|
||||
)
|
||||
})}
|
||||
<button onClick={() => setLogPage(p => Math.min(Math.ceil(logs.length / pageSize), p + 1))} disabled={logPage >= Math.ceil(logs.length / pageSize)} className="px-2 py-1 text-xs border border-gray-200 rounded hover:bg-gray-50 disabled:opacity-30">下一页</button>
|
||||
<button onClick={() => setLogPage(Math.ceil(logs.length / pageSize))} disabled={logPage >= Math.ceil(logs.length / pageSize)} className="px-2 py-1 text-xs border border-gray-200 rounded hover:bg-gray-50 disabled:opacity-30">末页</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{props.sslMode === 'letsencrypt' && (
|
||||
<div className="rounded-md border border-amber-200 bg-amber-50 p-3 text-xs text-amber-800">
|
||||
纯 IP 证书需要服务器安装 Certbot 5.4+,且验证时 80 端口必须能被 Let’s Encrypt 访问。IP 证书是短有效期证书,certbot 需要保持自动续签。
|
||||
</div>
|
||||
)}
|
||||
|
||||
{props.sslMode === 'self_signed' && (
|
||||
<div className="rounded-md border border-gray-100 bg-gray-50 p-3 text-xs text-gray-600">
|
||||
自签证书可以加密面板和 VNC,但浏览器会提示证书不受信任;证书快到期时系统会自动重新签发。
|
||||
</div>
|
||||
)}
|
||||
|
||||
{props.sslMode === 'uploaded' && (
|
||||
<div className="grid gap-3 lg:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-gray-500">证书 PEM / fullchain.pem</label>
|
||||
<textarea value={props.certPEM} onChange={(e) => props.onCertChange(e.target.value)} rows={7} className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 font-mono text-xs text-black" placeholder="-----BEGIN CERTIFICATE-----" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-gray-500">私钥 PEM / privkey.pem</label>
|
||||
<textarea value={props.keyPEM} onChange={(e) => props.onKeyChange(e.target.value)} rows={7} className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 font-mono text-xs text-black" placeholder="-----BEGIN PRIVATE KEY-----" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedSSL?.certificate ? (
|
||||
<div className="rounded-md border border-gray-100 bg-gray-50 p-3 text-xs text-gray-600">
|
||||
<div className="flex items-center gap-2 text-gray-800">
|
||||
<Lock className="h-3.5 w-3.5" />
|
||||
当前证书:{selectedSSL.certificate.valid ? '有效' : '已过期或未生效'}
|
||||
</div>
|
||||
<div className="mt-1 font-mono">到期时间:{selectedSSL.certificate.not_after}</div>
|
||||
<div className="mt-1 truncate font-mono" title={selectedSSL.cert_path}>证书路径:{selectedSSL.cert_path || '-'}</div>
|
||||
{selectedSSL.last_error && <div className="mt-1 text-red-600">最近错误:{selectedSSL.last_error}</div>}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border border-gray-100 bg-gray-50 p-3 text-xs text-gray-600">
|
||||
{props.sslMode === 'uploaded' ? '上传来源还没有保存证书,请粘贴证书和私钥后保存。' : '当前来源还没有保存证书,保存 SSL 设置时会自动生成或申请。'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label className="flex items-center gap-2 text-xs text-gray-500">
|
||||
<input type="checkbox" checked={props.applyNow} onChange={(e) => props.onApplyNowChange(e.target.checked)} className="h-4 w-4 rounded border-gray-300" />
|
||||
保存后自动重启服务并立即生效
|
||||
</label>
|
||||
|
||||
<button onClick={props.onSave} disabled={props.savingSSL} className="inline-flex w-full items-center justify-center gap-2 rounded-md bg-black px-4 py-2 text-sm text-white hover:bg-gray-800 disabled:opacity-50">
|
||||
<Upload className="h-4 w-4" />
|
||||
{props.savingSSL ? '保存中...' : '保存 SSL 设置'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface LoginLogCardProps {
|
||||
logs: LoginLog[]
|
||||
logPage: number
|
||||
pageSize: number
|
||||
totalPages: number
|
||||
setLogPage: Dispatch<SetStateAction<number>>
|
||||
}
|
||||
|
||||
function LoginLogCard({ logs, logPage, pageSize, totalPages, setLogPage }: LoginLogCardProps) {
|
||||
return (
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-5">
|
||||
<h2 className="mb-4 flex items-center gap-2 text-sm font-semibold text-black">
|
||||
<LogIn className="h-4 w-4" />登录日志
|
||||
</h2>
|
||||
{logs.length === 0 ? (
|
||||
<p className="text-sm text-gray-400">暂无登录记录</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-100 text-gray-400">
|
||||
<th className="w-40 py-2 text-left font-medium"><span className="inline-flex items-center gap-1"><Clock className="h-3 w-3" />时间</span></th>
|
||||
<th className="py-2 text-left font-medium">用户名</th>
|
||||
<th className="py-2 text-left font-medium"><span className="inline-flex items-center gap-1"><Globe className="h-3 w-3" />IP</span></th>
|
||||
<th className="py-2 text-left font-medium"><span className="inline-flex items-center gap-1"><Monitor className="h-3 w-3" />设备</span></th>
|
||||
<th className="py-2 text-left font-medium">结果</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-50">
|
||||
{logs.slice((logPage - 1) * pageSize, logPage * pageSize).map((log, index) => (
|
||||
<tr key={`${log.time}-${index}`}>
|
||||
<td className="whitespace-nowrap py-1.5 font-mono text-gray-500">{log.time}</td>
|
||||
<td className="py-1.5 text-gray-700">{log.username}</td>
|
||||
<td className="py-1.5 font-mono text-gray-500">{log.ip}</td>
|
||||
<td className="max-w-[180px] truncate py-1.5 text-gray-500" title={log.user_agent}>{formatUA(log.user_agent)}</td>
|
||||
<td className="py-1.5">
|
||||
<span className={`rounded px-1.5 py-0.5 text-xs ${log.success ? 'bg-gray-100 text-gray-700' : 'bg-red-50 text-red-600'}`}>
|
||||
{log.success ? '成功' : '失败'}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{logs.length > pageSize && (
|
||||
<div className="mt-3 flex items-center justify-between border-t border-gray-100 pt-3">
|
||||
<span className="text-xs text-gray-400">共 {logs.length} 条,第 {logPage}/{totalPages} 页</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<button onClick={() => setLogPage(1)} disabled={logPage === 1} className="rounded border border-gray-200 px-2 py-1 text-xs hover:bg-gray-50 disabled:opacity-30">首页</button>
|
||||
<button onClick={() => setLogPage(p => Math.max(1, p - 1))} disabled={logPage === 1} className="rounded border border-gray-200 px-2 py-1 text-xs hover:bg-gray-50 disabled:opacity-30">上一页</button>
|
||||
<button onClick={() => setLogPage(p => Math.min(totalPages, p + 1))} disabled={logPage >= totalPages} className="rounded border border-gray-200 px-2 py-1 text-xs hover:bg-gray-50 disabled:opacity-30">下一页</button>
|
||||
<button onClick={() => setLogPage(totalPages)} disabled={logPage >= totalPages} className="rounded border border-gray-200 px-2 py-1 text-xs hover:bg-gray-50 disabled:opacity-30">末页</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatUA(ua: string): string {
|
||||
// Extract browser/OS info from UA string
|
||||
const parts: string[] = []
|
||||
if (ua.includes('Windows NT')) parts.push('Windows')
|
||||
else if (ua.includes('Mac OS X')) parts.push('macOS')
|
||||
|
||||
@@ -136,6 +136,16 @@ export interface IPv6Status {
|
||||
prefixes: IPv6PrefixInfo[]
|
||||
}
|
||||
|
||||
export interface IPv4PrefixInfo {
|
||||
interface: string
|
||||
address: string
|
||||
prefix: string
|
||||
prefix_len: number
|
||||
subnet_mask: string
|
||||
gateway: string
|
||||
source: string
|
||||
}
|
||||
|
||||
export interface DashboardStats {
|
||||
total_containers: number
|
||||
running: number
|
||||
@@ -161,6 +171,93 @@ export interface HostInfo {
|
||||
load: { load1: number; load5: number; load15: number }
|
||||
}
|
||||
|
||||
export interface HostProbeReport {
|
||||
generated_at: string
|
||||
hostname: string
|
||||
kernel: string
|
||||
os: string
|
||||
cpu: {
|
||||
model: string
|
||||
cores: number
|
||||
threads: number
|
||||
architecture: string
|
||||
flags: string[]
|
||||
has_integrated_gpu: boolean
|
||||
virtualization: boolean
|
||||
virtualization_key: string
|
||||
}
|
||||
memory: {
|
||||
total_mb: number
|
||||
used_mb: number
|
||||
free_mb: number
|
||||
modules: Array<{
|
||||
locator: string
|
||||
size: string
|
||||
type: string
|
||||
speed: string
|
||||
manufacturer: string
|
||||
part_number: string
|
||||
serial_number: string
|
||||
}>
|
||||
}
|
||||
disks: Array<{
|
||||
name: string
|
||||
path: string
|
||||
model: string
|
||||
serial: string
|
||||
size_bytes: number
|
||||
type: string
|
||||
rotational: boolean
|
||||
mountpoints: string[]
|
||||
health: string
|
||||
health_detail: string
|
||||
smart?: {
|
||||
available: boolean
|
||||
life_used_percent?: number
|
||||
power_on_hours?: number
|
||||
power_cycle_count?: number
|
||||
read_data_bytes?: number
|
||||
written_data_bytes?: number
|
||||
read_commands?: number
|
||||
write_commands?: number
|
||||
wear_leveling_count?: string
|
||||
erase_count?: string
|
||||
media_errors?: number
|
||||
}
|
||||
}>
|
||||
network_interfaces: Array<{
|
||||
name: string
|
||||
mac: string
|
||||
state: string
|
||||
speed_mbps: number
|
||||
driver: string
|
||||
model: string
|
||||
ipv4: Array<{ interface: string; address: string; prefix_len: number; scope: string; gateway?: string }>
|
||||
ipv6: Array<{ interface: string; address: string; prefix_len: number; scope: string; gateway?: string }>
|
||||
}>
|
||||
public_ipv4: string[]
|
||||
ipv4_addresses: Array<{ interface: string; address: string; prefix_len: number; scope: string; gateway?: string }>
|
||||
ipv4_prefixes: IPv4PrefixInfo[]
|
||||
ipv6_addresses: Array<{ interface: string; address: string; prefix_len: number; scope: string; gateway?: string }>
|
||||
ipv6_prefixes: IPv6PrefixInfo[]
|
||||
gateways: Array<{ family: string; interface: string; gateway: string }>
|
||||
gpus: Array<{ name: string; vendor: string; driver: string; type: string }>
|
||||
runtime: {
|
||||
lxc_available: boolean
|
||||
kvm_available: boolean
|
||||
dev_kvm: boolean
|
||||
nested_virtualization: boolean
|
||||
nested_detail: string
|
||||
support_mode: string
|
||||
}
|
||||
system: {
|
||||
uptime_seconds: number
|
||||
uptime_text: string
|
||||
process_count: number
|
||||
}
|
||||
environment: Array<{ key: string; label: string; ok: boolean; required: boolean; detail: string }>
|
||||
}
|
||||
|
||||
export interface ContainerUsage {
|
||||
memory_usage_bytes: number
|
||||
memory_total_bytes?: number
|
||||
@@ -220,6 +317,47 @@ export interface AuditLog {
|
||||
export const getLoginLogs = () =>
|
||||
api.get<APIResponse<LoginLog[]>>('/login-logs')
|
||||
|
||||
export interface SSLCertificateInfo {
|
||||
subject: string
|
||||
issuer: string
|
||||
dns_names: string[]
|
||||
ip_names: string[]
|
||||
not_before: string
|
||||
not_after: string
|
||||
valid: boolean
|
||||
}
|
||||
|
||||
export interface SSLSettings {
|
||||
enabled: boolean
|
||||
mode: 'disabled' | 'letsencrypt' | 'self_signed' | 'uploaded'
|
||||
target: string
|
||||
email?: string
|
||||
cert_path?: string
|
||||
key_path?: string
|
||||
last_issued_at?: string
|
||||
last_error?: string
|
||||
detected_host?: string
|
||||
certificate?: SSLCertificateInfo
|
||||
mode_certificates?: Record<string, SSLSettings>
|
||||
needs_restart?: boolean
|
||||
}
|
||||
|
||||
export interface UpdateSSLSettingsRequest {
|
||||
enabled: boolean
|
||||
mode: 'disabled' | 'letsencrypt' | 'self_signed' | 'uploaded'
|
||||
target?: string
|
||||
email?: string
|
||||
cert_pem?: string
|
||||
key_pem?: string
|
||||
apply_now?: boolean
|
||||
}
|
||||
|
||||
export const getSSLSettings = () =>
|
||||
api.get<APIResponse<SSLSettings>>('/ssl')
|
||||
|
||||
export const updateSSLSettings = (data: UpdateSSLSettingsRequest) =>
|
||||
api.put<APIResponse<SSLSettings>>('/ssl', data)
|
||||
|
||||
// Containers
|
||||
export const getContainers = () =>
|
||||
api.get<APIResponse<Container[]>>('/containers')
|
||||
@@ -393,6 +531,9 @@ export const getDashboard = () =>
|
||||
export const getHostInfo = () =>
|
||||
api.get<APIResponse<HostInfo>>('/host-info')
|
||||
|
||||
export const getHostReport = () =>
|
||||
api.get<APIResponse<HostProbeReport>>('/host-report')
|
||||
|
||||
// Snapshots
|
||||
export interface Snapshot {
|
||||
id: string
|
||||
@@ -456,10 +597,9 @@ export const getWebSSHUrl = (containerName: string) => {
|
||||
return `${protocol}//${window.location.host}/api/ssh?${params.toString()}`
|
||||
}
|
||||
|
||||
export const getWebVNCUrl = (containerName: string, ticket?: string) => {
|
||||
export const getWebVNCUrl = (containerName: string) => {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
const params = new URLSearchParams({ container: containerName })
|
||||
if (ticket) params.set('ticket', ticket)
|
||||
return `${protocol}//${window.location.host}/api/vnc?${params.toString()}`
|
||||
}
|
||||
|
||||
@@ -574,6 +714,15 @@ export const createWebSSHTicket = (containerName: string) =>
|
||||
export const createVNCTicket = (containerName: string) =>
|
||||
api.post<APIResponse<{ ticket: string }>>('/vnc-ticket', { container_name: containerName })
|
||||
|
||||
// Language
|
||||
export type PanelLanguage = 'zh' | 'en'
|
||||
|
||||
export const getLanguage = () =>
|
||||
api.get<APIResponse<{ language: PanelLanguage }>>('/language')
|
||||
|
||||
export const updateLanguage = (language: PanelLanguage) =>
|
||||
api.post<APIResponse<{ language: PanelLanguage }>>('/language', { language })
|
||||
|
||||
// Version
|
||||
export const getVersion = () =>
|
||||
api.get<APIResponse<{ version: string }>>('/version')
|
||||
|
||||
@@ -0,0 +1,910 @@
|
||||
const exact: Record<string, string> = {
|
||||
'控制面板': 'Dashboard',
|
||||
'共': 'Total',
|
||||
'第': 'Page',
|
||||
'页': 'page',
|
||||
'个': 'items',
|
||||
'条': 'records',
|
||||
'核': 'cores',
|
||||
'个容器': 'containers',
|
||||
'条操作记录': 'audit records',
|
||||
'个地址': 'addresses',
|
||||
'列表': 'List',
|
||||
'主机资源': 'Host Resources',
|
||||
'主机资源状态': 'Host Resource Status',
|
||||
'容器管理': 'Containers',
|
||||
'镜像管理': 'Images',
|
||||
'安全告警': 'Security Alerts',
|
||||
'快照管理': 'Snapshots',
|
||||
'路由管理': 'Routing',
|
||||
'操作日志': 'Audit Logs',
|
||||
'子用户管理': 'Sub Users',
|
||||
'API 集成': 'API Integration',
|
||||
'宿主机信息': 'Host Info',
|
||||
'面板设置': 'Panel Settings',
|
||||
'退出登录': 'Log out',
|
||||
'亮色模式': 'Light Mode',
|
||||
'暗黑模式': 'Dark Mode',
|
||||
'切换亮色模式': 'Switch to light mode',
|
||||
'切换暗黑模式': 'Switch to dark mode',
|
||||
'切换侧边栏': 'Toggle sidebar',
|
||||
'刷新': 'Refresh',
|
||||
'搜索': 'Search',
|
||||
'复制': 'Copy',
|
||||
'编辑': 'Edit',
|
||||
'删除': 'Delete',
|
||||
'保存': 'Save',
|
||||
'提交': 'Submit',
|
||||
'应用': 'Apply',
|
||||
'查看': 'View',
|
||||
'详情': 'Details',
|
||||
'返回': 'Back',
|
||||
'返回列表': 'Back to list',
|
||||
'取消': 'Cancel',
|
||||
'确认': 'Confirm',
|
||||
'确定': 'OK',
|
||||
'完成': 'Done',
|
||||
'失败': 'Failed',
|
||||
'成功': 'Success',
|
||||
'提示': 'Notice',
|
||||
'警告': 'Warning',
|
||||
'开机': 'Start',
|
||||
'关机': 'Stop',
|
||||
'重启': 'Restart',
|
||||
'重装': 'Reinstall',
|
||||
'创建': 'Create',
|
||||
'在线': 'Online',
|
||||
'离线': 'Offline',
|
||||
'永久': 'Permanent',
|
||||
'长期有效': 'No expiration',
|
||||
'长期': 'No expiration',
|
||||
'不限制': 'Unlimited',
|
||||
'未设置流量限制': 'No traffic limit set',
|
||||
'未设置': 'Not set',
|
||||
'已选': 'Selected',
|
||||
',已选': ', selected',
|
||||
'筛选后': 'Filtered',
|
||||
',筛选后': ', filtered',
|
||||
'每页数量': 'Items per page',
|
||||
'任务中': 'In task',
|
||||
'1周': '1 week',
|
||||
'1 周': '1 week',
|
||||
'资源配置': 'Resource Configuration',
|
||||
'实时状态': 'Live Status',
|
||||
'连接信息': 'Connection Info',
|
||||
'管理链接': 'Management Link',
|
||||
'NAT 管理': 'NAT Management',
|
||||
'快照': 'Snapshots',
|
||||
'系统': 'System',
|
||||
'全部类型': 'All types',
|
||||
'全部系统': 'All systems',
|
||||
'全部状态': 'All statuses',
|
||||
'类型筛选': 'Type filter',
|
||||
'系统筛选': 'System filter',
|
||||
'状态筛选': 'Status filter',
|
||||
'内网': 'Private IP',
|
||||
'内网 IP': 'Private IP',
|
||||
'策略封禁': 'Policy Blocked',
|
||||
'已封禁': 'Blocked',
|
||||
'已到期': 'Expired',
|
||||
'识别码': 'Identifier',
|
||||
'CPU 累计时间': 'CPU Total Time',
|
||||
'创建时间': 'Created At',
|
||||
'网络速率': 'Network Speed',
|
||||
'IO 速度': 'IO Speed',
|
||||
'月流量': 'Monthly Traffic',
|
||||
'统计信息': 'Statistics',
|
||||
'CPU 使用率': 'CPU Usage',
|
||||
'内存使用': 'Memory Usage',
|
||||
'网络流量': 'Network Traffic',
|
||||
'磁盘IO': 'Disk IO',
|
||||
'磁盘 IO': 'Disk IO',
|
||||
'负载': 'Load',
|
||||
'平均': 'Average',
|
||||
'峰值': 'Peak',
|
||||
'容量': 'Capacity',
|
||||
'累计': 'Total',
|
||||
'读': 'Read',
|
||||
'写': 'Write',
|
||||
'入': 'In',
|
||||
'出': 'Out',
|
||||
'运行中': 'Running',
|
||||
'已停止': 'Stopped',
|
||||
'已完成': 'Completed',
|
||||
'等待中': 'Pending',
|
||||
'执行中': 'Running',
|
||||
'未知': 'Unknown',
|
||||
'必要': 'Required',
|
||||
'可选': 'Optional',
|
||||
'用户名': 'Username',
|
||||
'密码': 'Password',
|
||||
'输入用户名': 'Enter username',
|
||||
'输入密码': 'Enter password',
|
||||
'登录': 'Log in',
|
||||
'登录中...': 'Logging in...',
|
||||
'登录失败,请检查用户名和密码': 'Login failed. Check your username and password.',
|
||||
'Authentication required': 'Authentication required',
|
||||
'Administrator permission required': 'Administrator permission required',
|
||||
'Method not allowed': 'Method not allowed',
|
||||
'Invalid request body': 'Invalid request body',
|
||||
'Invalid credentials': 'Invalid credentials',
|
||||
'Access denied': 'Access denied',
|
||||
'Access denied to this container': 'Access denied to this container',
|
||||
'Container not found': 'Container not found',
|
||||
'Template not found': 'Template not found',
|
||||
'Template is required': 'Template is required',
|
||||
'Template is not enabled or downloaded': 'Template is not enabled or downloaded',
|
||||
'Container name is required': 'Container name is required',
|
||||
'Container created successfully': 'Container created successfully',
|
||||
'Password changed successfully': 'Password changed successfully',
|
||||
'SSL settings saved': 'SSL settings saved',
|
||||
'Save SSL settings failed': 'Save SSL settings failed',
|
||||
'Task deleted': 'Task deleted',
|
||||
'Snapshot deleted': 'Snapshot deleted',
|
||||
'Snapshot restored': 'Snapshot restored',
|
||||
'Security check completed': 'Security check completed',
|
||||
'当前密码不正确': 'Current password is incorrect',
|
||||
'密码不正确': 'Password is incorrect',
|
||||
'新密码至少 6 位': 'New password must be at least 6 characters',
|
||||
'用户名至少 3 位': 'Username must be at least 3 characters',
|
||||
'密码加密失败': 'Failed to hash password',
|
||||
'保存配置失败': 'Failed to save configuration',
|
||||
'密码修改成功': 'Password changed successfully',
|
||||
'用户名修改成功': 'Username changed successfully',
|
||||
'容器已到期,不允许此操作': 'Container has expired. This action is not allowed.',
|
||||
'容器管理登录': 'Container Access Login',
|
||||
'操作失败': 'Action failed',
|
||||
'错误': 'Error',
|
||||
'保存失败': 'Save failed',
|
||||
'重装失败': 'Reinstall failed',
|
||||
'密码重置失败': 'Password reset failed',
|
||||
'端口配额已满': 'Port quota reached',
|
||||
'输入错误': 'Input error',
|
||||
'请输入有效的内部端口': 'Enter a valid internal port',
|
||||
'密码长度必须为 8-64 位': 'Password length must be 8-64 characters',
|
||||
'密码不能包含空白字符': 'Password cannot contain whitespace',
|
||||
'密码至少需要包含字母': 'Password must contain at least one letter',
|
||||
'密码至少需要包含数字': 'Password must contain at least one number',
|
||||
'密码格式不正确': 'Invalid password format',
|
||||
'策略临时封禁': 'Temporarily blocked by policy',
|
||||
'虚拟机被策略临时封禁,暂不能执行操作。': 'This VM is temporarily blocked by policy and cannot perform actions.',
|
||||
'确定要删除容器': 'Delete container',
|
||||
'吗?此操作不可撤销。': '? This action cannot be undone.',
|
||||
'容器名称不能包含空格': 'Container name cannot contain spaces',
|
||||
'该容器名称已存在': 'Container name already exists',
|
||||
'请填写容器名称并选择系统模板': 'Enter a container name and select a system template',
|
||||
'资源配置有误': 'Invalid resource configuration',
|
||||
'请按红色提示修改 vCPU、内存或磁盘配置': 'Fix the vCPU, memory, or disk fields marked in red',
|
||||
'创建失败': 'Create failed',
|
||||
'创建新容器': 'Create New Container',
|
||||
'批量创建数量': 'Batch Count',
|
||||
'虚拟化架构': 'Virtualization',
|
||||
'LXC 容器': 'LXC Container',
|
||||
'KVM 虚拟机': 'KVM VM',
|
||||
'系统模板': 'System Template',
|
||||
'搜索名称、ID、UUID、IP': 'Search name, ID, UUID, IP',
|
||||
'带宽 (Mbps)': 'Bandwidth (Mbps)',
|
||||
'双向统计': 'Total In+Out',
|
||||
'入/出分离': 'Separate In/Out',
|
||||
'GB (0=不限制)': 'GB (0=unlimited)',
|
||||
'入站 (GB)': 'Inbound (GB)',
|
||||
'出站 (GB)': 'Outbound (GB)',
|
||||
'NAT 端口映射数量': 'NAT Port Mapping Count',
|
||||
'子用户快照上限': 'Sub-user Snapshot Limit',
|
||||
'到期时间': 'Expiration Time',
|
||||
'不选择则长期有效;选择日期后,到期会自动关机。': 'Leave blank for no expiration. If a date is selected, the container will shut down automatically when it expires.',
|
||||
'创建中...': 'Creating...',
|
||||
'请输入 vCPU': 'Enter vCPU',
|
||||
'内存 (MB)': 'Memory (MB)',
|
||||
'磁盘 (GB)': 'Disk (GB)',
|
||||
'IO 速度 (MB/s)': 'IO Speed (MB/s)',
|
||||
'将创建': 'Will create',
|
||||
'暂无可用的': 'No available',
|
||||
'不能小于': 'Cannot be less than',
|
||||
'不能大于': 'Cannot be greater than',
|
||||
'KVM vCPU 必须是整数': 'KVM vCPU must be an integer',
|
||||
'请输入内存': 'Enter memory',
|
||||
'请输入磁盘': 'Enter disk',
|
||||
'轮换失败': 'Rotation failed',
|
||||
'获取操作日志失败': 'Failed to load audit logs',
|
||||
'获取登录日志失败': 'Failed to load login logs',
|
||||
'暂无子用户': 'No sub-users',
|
||||
'容器名称': 'Container Name',
|
||||
'最后登录': 'Last Login',
|
||||
'从未登录': 'Never logged in',
|
||||
'查看密码': 'View Password',
|
||||
'查看操作日志': 'View Audit Logs',
|
||||
'查看登录日志': 'View Login Logs',
|
||||
'轮换密码': 'Rotate Password',
|
||||
'轮换中...': 'Rotating...',
|
||||
'用户': 'User',
|
||||
'未保存,请轮换生成新密码': 'Not saved. Rotate to generate a new password.',
|
||||
'操作时间': 'Action Time',
|
||||
'登录时间': 'Login Time',
|
||||
'登录 IP': 'Login IP',
|
||||
'请输入当前密码以确认修改': 'Enter current password to confirm changes',
|
||||
'至少填写新密码或新用户名中的一项': 'Enter at least a new password or a new username',
|
||||
'用户名已修改': 'Username changed',
|
||||
'用户名修改失败': 'Username change failed',
|
||||
'密码已修改': 'Password changed',
|
||||
'密码修改失败': 'Password change failed',
|
||||
'下次登录生效': 'Takes effect at next login',
|
||||
'修改失败': 'Change failed',
|
||||
'账号、安全证书与登录日志': 'Account, certificates, and login logs',
|
||||
'SSL 设置已保存,服务正在重启。稍后请用新的协议重新打开面板。': 'SSL settings saved. The service is restarting. Reopen the panel with the new protocol shortly.',
|
||||
'SSL 设置已保存,重启 clicd 服务后生效。': 'SSL settings saved. Restart the clicd service to apply them.',
|
||||
'SSL 设置保存失败': 'Failed to save SSL settings',
|
||||
'纯 IP 证书需要服务器安装 Certbot 5.4+,且验证时 80 端口必须能被 Let’s Encrypt 访问。IP 证书是短有效期证书,certbot 需要保持自动续签。': 'Pure IP certificates require Certbot 5.4+ on the server, and port 80 must be reachable by Let’s Encrypt during validation. IP certificates are short-lived, so certbot auto-renewal must remain enabled.',
|
||||
'自签证书可以加密面板和 VNC,但浏览器会提示证书不受信任;证书快到期时系统会自动重新签发。': 'Self-signed certificates can encrypt the panel and VNC, but browsers will show an untrusted certificate warning. The system will renew them automatically before expiration.',
|
||||
'上传来源还没有保存证书,请粘贴证书和私钥后保存。': 'No certificate has been saved for the uploaded source. Paste the certificate and private key, then save.',
|
||||
'当前来源还没有保存证书,保存 SSL 设置时会自动生成或申请。': 'No certificate has been saved for the current source. It will be generated or requested when SSL settings are saved.',
|
||||
'暂无容器': 'No containers',
|
||||
'暂无快照': 'No snapshots',
|
||||
'暂无操作日志': 'No audit logs',
|
||||
'暂无登录日志': 'No login logs',
|
||||
'暂无登录记录': 'No login records',
|
||||
'暂无 NAT4 端口映射': 'No NAT4 port mappings',
|
||||
'暂无 IPv6 地址分配': 'No IPv6 assignments',
|
||||
'暂无镜像': 'No images',
|
||||
'暂无数据': 'No data',
|
||||
'容器': 'Container',
|
||||
'名称': 'Name',
|
||||
'状态': 'Status',
|
||||
'剩余时间': 'Time Left',
|
||||
'配置': 'Config',
|
||||
'镜像': 'Image',
|
||||
'内存': 'Memory',
|
||||
'磁盘': 'Disk',
|
||||
'流量': 'Traffic',
|
||||
'操作': 'Actions',
|
||||
'类型': 'Type',
|
||||
'创建者': 'Creator',
|
||||
'管理员密码': 'Admin Password',
|
||||
'SSH 密码': 'SSH Password',
|
||||
'SSH 地址': 'SSH Address',
|
||||
'RDP 地址': 'RDP Address',
|
||||
'VNC 端口': 'VNC Port',
|
||||
'点击隐藏': 'Click to hide',
|
||||
'点击显示': 'Click to show',
|
||||
'编辑资源限制': 'Edit resource limits',
|
||||
'编辑流量限制': 'Edit traffic limit',
|
||||
'修改到期时间': 'Change expiration time',
|
||||
'新 SSH 密码': 'New SSH Password',
|
||||
'生成随机密码': 'Generate random password',
|
||||
'密码已修改成功': 'Password changed successfully',
|
||||
'修改中...': 'Changing...',
|
||||
'确认修改': 'Confirm Change',
|
||||
'容器不存在': 'Container not found',
|
||||
'容器未运行,请先开机': 'Container is not running. Start it first.',
|
||||
'VNC 控制台暂不可用,请确认 KVM 虚拟机已开机并刷新页面': 'VNC console is unavailable. Make sure the KVM VM is running and refresh the page.',
|
||||
'虚拟机被策略临时封禁': 'VM temporarily blocked by policy',
|
||||
'虚拟机被策略临时封禁,连接信息暂不可用。': 'This VM is temporarily blocked by policy. Connection info is unavailable.',
|
||||
'已达到管理员分配的 NAT 端口配额。': 'The NAT port quota assigned by the administrator has been reached.',
|
||||
'保存端口映射失败': 'Failed to save port mapping',
|
||||
'删除端口映射失败': 'Failed to delete port mapping',
|
||||
'删除映射': 'Delete Mapping',
|
||||
'确定要删除这条映射规则吗?': 'Delete this mapping rule?',
|
||||
'快照配额已满': 'Snapshot quota reached',
|
||||
'已达到管理员设置的快照配额,请先删除旧快照。': 'The snapshot quota set by the administrator has been reached. Delete old snapshots first.',
|
||||
'拍摄快照': 'Take Snapshot',
|
||||
'拍摄快照需要先关机,完成后会自动重启容器': 'Taking a snapshot requires shutdown first. The container will restart automatically afterward',
|
||||
'是否继续?': 'Continue?',
|
||||
'创建快照失败': 'Failed to create snapshot',
|
||||
'参数错误': 'Invalid parameters',
|
||||
'自动快照周期最低是 1 天一次。': 'The minimum automatic snapshot interval is once per day.',
|
||||
'定时快照失败': 'Scheduled snapshot failed',
|
||||
'保存快照配额失败': 'Failed to save snapshot quota',
|
||||
'删除快照失败': 'Failed to delete snapshot',
|
||||
'恢复快照失败': 'Failed to restore snapshot',
|
||||
'确定恢复到': 'Restore to',
|
||||
'当前容器数据会被覆盖。': 'Current container data will be overwritten.',
|
||||
'确定删除': 'Delete',
|
||||
'的快照吗?': 'snapshot?',
|
||||
'新建快照': 'New Snapshot',
|
||||
'定时设置': 'Schedule Settings',
|
||||
'定时快照': 'Scheduled Snapshot',
|
||||
'处理中...': 'Processing...',
|
||||
'快照数量:': 'Snapshot count:',
|
||||
'子用户配额:': 'Sub-user quota:',
|
||||
'定时状态:': 'Schedule status:',
|
||||
'下次执行:': 'Next run:',
|
||||
'未开启': 'Off',
|
||||
'已开启': 'On',
|
||||
'每': 'Every',
|
||||
'执行': 'run',
|
||||
'子用户每台容器快照上限': 'Sub-user snapshot limit per container',
|
||||
'自动快照周期': 'Automatic snapshot interval',
|
||||
'天': 'days',
|
||||
'小时': 'hours',
|
||||
'分钟': 'minutes',
|
||||
'秒': 'seconds',
|
||||
'大小': 'Size',
|
||||
'手动': 'Manual',
|
||||
'定时': 'Scheduled',
|
||||
'时间': 'Time',
|
||||
'设备': 'Device',
|
||||
'结果': 'Result',
|
||||
'地址': 'Address',
|
||||
'前缀': 'Prefix',
|
||||
'出口网卡': 'Uplink',
|
||||
'协议': 'Protocol',
|
||||
'说明': 'Description',
|
||||
'端口': 'Port',
|
||||
'容器端口': 'Container Port',
|
||||
'宿主机端口': 'Host Port',
|
||||
'容器 IPv4': 'Container IPv4',
|
||||
'IPv6 地址': 'IPv6 Address',
|
||||
'LXC 名称': 'LXC Name',
|
||||
'快照时间': 'Snapshot Time',
|
||||
'删除快照': 'Delete Snapshot',
|
||||
'全局快照列表': 'Global snapshot list',
|
||||
'主机名': 'Hostname',
|
||||
'操作系统': 'Operating System',
|
||||
'内核': 'Kernel',
|
||||
'生成时间': 'Generated At',
|
||||
'系统概览': 'System Overview',
|
||||
'公网与路由': 'Public Network & Routing',
|
||||
'内存条': 'Memory Modules',
|
||||
'硬盘与健康': 'Disks & Health',
|
||||
'网卡': 'Network Interfaces',
|
||||
'显卡': 'GPUs',
|
||||
'环境支持': 'Environment Support',
|
||||
'服务管理器 systemd/OpenRC': 'Service Manager systemd/OpenRC',
|
||||
'LXC 创建工具': 'LXC Create Tool',
|
||||
'LXC 启动工具': 'LXC Start Tool',
|
||||
'iptables 网络规则': 'iptables Network Rules',
|
||||
'iproute2 网络工具': 'iproute2 Network Tool',
|
||||
'conntrack 安全扫描': 'conntrack Security Scan',
|
||||
'QEMU/KVM 虚拟机': 'QEMU/KVM Virtualization',
|
||||
'KVM cloud-init ISO 工具': 'KVM cloud-init ISO Tool',
|
||||
'ISO 备用工具': 'ISO Fallback Tool',
|
||||
'硬盘健康检测': 'Disk Health Check',
|
||||
'Certbot 证书工具 >= 5.4': 'Certbot Certificate Tool >= 5.4',
|
||||
'/dev/kvm 硬件虚拟化': '/dev/kvm Hardware Virtualization',
|
||||
'IPv4 转发': 'IPv4 Forwarding',
|
||||
'lxcfs 服务': 'lxcfs Service',
|
||||
'libvirt 服务': 'libvirt Service',
|
||||
'正在探测宿主机环境...': 'Probing host environment...',
|
||||
'暂未获取到宿主机信息': 'No host information available',
|
||||
'面板资源状态与容器概览': 'Panel resource status and container overview',
|
||||
'宿主机资源状态与容器概览': 'Host resource status and container overview',
|
||||
'账号设置': 'Account Settings',
|
||||
'当前用户名': 'Current Username',
|
||||
'新用户名,留空则不修改': 'New Username, leave blank to keep unchanged',
|
||||
'新密码,留空则不修改': 'New Password, leave blank to keep unchanged',
|
||||
'当前密码,验证身份': 'Current Password, for verification',
|
||||
'至少 3 位': 'At least 3 characters',
|
||||
'至少 6 位': 'At least 6 characters',
|
||||
'输入当前密码以确认修改': 'Enter current password to confirm changes',
|
||||
'保存修改': 'Save Changes',
|
||||
'SSL 证书': 'SSL Certificate',
|
||||
'启用 HTTPS / WSS': 'Enable HTTPS / WSS',
|
||||
'IP / 域名': 'IP / Domain',
|
||||
'服务器公网 IP 或域名': 'Server public IP or domain',
|
||||
'邮箱,可选': 'Email, optional',
|
||||
'自签证书': 'Self-signed Certificate',
|
||||
'上传证书': 'Uploaded Certificate',
|
||||
'证书 PEM / fullchain.pem': 'Certificate PEM / fullchain.pem',
|
||||
'私钥 PEM / privkey.pem': 'Private Key PEM / privkey.pem',
|
||||
'保存后自动重启服务并立即生效': 'Restart service automatically after saving',
|
||||
'保存中...': 'Saving...',
|
||||
'保存 SSL 设置': 'Save SSL Settings',
|
||||
'登录日志': 'Login Logs',
|
||||
'首页': 'First',
|
||||
'上一页': 'Previous',
|
||||
'下一页': 'Next',
|
||||
'末页': 'Last',
|
||||
'搜索端口/容器...': 'Search port/container...',
|
||||
'搜索地址/容器...': 'Search address/container...',
|
||||
'NAT4 端口': 'NAT4 Ports',
|
||||
'NAT4 端口分配': 'NAT4 Port Allocation',
|
||||
'IPv6 地址分配': 'IPv6 Address Allocation',
|
||||
'剩余端口 / 端口总数': 'Available Ports / Total Ports',
|
||||
'剩余地址 / 地址总数': 'Available Addresses / Total Addresses',
|
||||
'已分配': 'Allocated',
|
||||
'充足': 'Enough',
|
||||
'容器列表': 'Container List',
|
||||
'刷新列表': 'Refresh list',
|
||||
'创建容器': 'Create Container',
|
||||
'点击"创建容器"开始': 'Click "Create Container" to start',
|
||||
'创建中': 'Creating',
|
||||
'批量创建': 'Batch Create',
|
||||
'导入容器': 'Import Container',
|
||||
'重置密码': 'Reset Password',
|
||||
'WebSSH': 'WebSSH',
|
||||
'WebVNC': 'WebVNC',
|
||||
'发送 Ctrl+Alt+Del': 'Send Ctrl+Alt+Del',
|
||||
'重新连接': 'Reconnect',
|
||||
'关闭': 'Close',
|
||||
'已连接': 'Connected',
|
||||
'连接中...': 'Connecting...',
|
||||
'已断开': 'Disconnected',
|
||||
'连接失败': 'Connection Failed',
|
||||
'正在连接 KVM VNC 控制台...': 'Connecting to KVM VNC console...',
|
||||
'WebVNC 已断开': 'WebVNC disconnected',
|
||||
'下载': 'Download',
|
||||
'下载中': 'Downloading',
|
||||
'启用': 'Enable',
|
||||
'停用': 'Disable',
|
||||
'已启用': 'Enabled',
|
||||
'未启用': 'Disabled',
|
||||
'系统镜像': 'System Images',
|
||||
'安全检查': 'Security Check',
|
||||
'告警列表': 'Alert List',
|
||||
'自动关机已开': 'Auto-stop on',
|
||||
'自动关机已关': 'Auto-stop off',
|
||||
'暂无安全告警': 'No security alerts',
|
||||
'严重': 'Critical',
|
||||
'高': 'High',
|
||||
'中': 'Medium',
|
||||
'低': 'Low',
|
||||
'管理员': 'Admin',
|
||||
'子用户': 'Sub User',
|
||||
'公网 IPv4': 'Public IPv4',
|
||||
'IPv4 地址': 'IPv4 Addresses',
|
||||
'IPv4 段': 'IPv4 Prefixes',
|
||||
'IPv6 段': 'IPv6 Prefixes',
|
||||
'网关': 'Gateways',
|
||||
'CPU 架构': 'CPU Architecture',
|
||||
'CPU 虚拟化指令': 'CPU Virtualization Flags',
|
||||
'CPU 核显': 'Integrated GPU',
|
||||
'运行能力': 'Runtime Capability',
|
||||
'KVM 嵌套虚拟化': 'KVM Nested Virtualization',
|
||||
'支持': 'Supported',
|
||||
'未检测到': 'Not detected',
|
||||
'检测到': 'Detected',
|
||||
'有效': 'Valid',
|
||||
'已过期或未生效': 'Expired or not active',
|
||||
'是': 'Yes',
|
||||
'否': 'No',
|
||||
'开启': 'On',
|
||||
'已关闭': 'Off',
|
||||
'自动': 'Auto',
|
||||
'默认': 'Default',
|
||||
'全部': 'All',
|
||||
'无': 'None',
|
||||
'根目录': 'Root',
|
||||
'版本': 'Version',
|
||||
'当前': 'Current',
|
||||
'最近': 'Recent',
|
||||
'来源': 'Source',
|
||||
'目标': 'Target',
|
||||
'描述': 'Description',
|
||||
'备注': 'Notes',
|
||||
'搜索容器...': 'Search containers...',
|
||||
'搜索镜像...': 'Search images...',
|
||||
'搜索日志...': 'Search logs...',
|
||||
'复制成功': 'Copied',
|
||||
'复制失败': 'Copy failed',
|
||||
'请稍后重试': 'Please try again later',
|
||||
'请稍后重试。': 'Please try again later.',
|
||||
'开机中...': 'Starting...',
|
||||
'关机中...': 'Stopping...',
|
||||
'重启中...': 'Restarting...',
|
||||
'删除中...': 'Deleting...',
|
||||
'重装中...': 'Reinstalling...',
|
||||
'开机中': 'Starting',
|
||||
'关机中': 'Stopping',
|
||||
'重启中': 'Restarting',
|
||||
'删除中': 'Deleting',
|
||||
'重装中': 'Reinstalling',
|
||||
'正在初始化': 'Initializing',
|
||||
'容器总数': 'Total Containers',
|
||||
'驱动/速率': 'Driver / Speed',
|
||||
'支持 KVM + LXC': 'KVM + LXC supported',
|
||||
'仅支持 LXC': 'LXC only',
|
||||
'未满足运行环境': 'Runtime requirements not met',
|
||||
'健康': 'Healthy',
|
||||
'异常': 'Abnormal',
|
||||
'核显': 'Integrated',
|
||||
'独显': 'Discrete',
|
||||
'获取镜像列表失败': 'Failed to load image list',
|
||||
'下载失败': 'Download failed',
|
||||
'删除失败': 'Delete failed',
|
||||
'取消失败': 'Cancel failed',
|
||||
'删除镜像': 'Delete Image',
|
||||
'确定要删除该镜像缓存吗?删除后需要重新下载才能使用。': 'Delete this image cache? You must download it again before using it.',
|
||||
'取消下载并清理临时文件': 'Cancel download and clean temporary files',
|
||||
'删除镜像缓存': 'Delete image cache',
|
||||
'取消中': 'Cancelling',
|
||||
'取消中...': 'Cancelling...',
|
||||
'下载中...': 'Downloading...',
|
||||
'阶段:': 'Stage:',
|
||||
'转换中': 'Converting',
|
||||
'端口扫描': 'Port scan',
|
||||
'横向扫描': 'Lateral scan',
|
||||
'暴力破解': 'Brute force',
|
||||
'DDoS/大规模扫描': 'DDoS / large-scale scan',
|
||||
'垃圾邮件': 'Spam',
|
||||
'恶意软件': 'Malware',
|
||||
'挖矿连接': 'Mining connection',
|
||||
'代理/VPN/Tor': 'Proxy / VPN / Tor',
|
||||
'UDP反射放大': 'UDP reflection amplification',
|
||||
'高危': 'High risk',
|
||||
'中危': 'Medium risk',
|
||||
'低危': 'Low risk',
|
||||
'告警自动关机': 'Auto shutdown on alerts',
|
||||
'相关连接记录': 'Related Connection Records',
|
||||
'查看相关记录': 'View related records',
|
||||
'告警原始记录': 'Raw Alert Record',
|
||||
'正在加载连接记录...': 'Loading connection records...',
|
||||
'暂无可用连接记录。历史告警对应的 conntrack 记录可能已经过期。': 'No connection records available. Conntrack records for historical alerts may have expired.',
|
||||
'源地址': 'Source Address',
|
||||
'目标地址': 'Target Address',
|
||||
'源IP': 'Source IP',
|
||||
'次数': 'Count',
|
||||
'等级': 'Severity',
|
||||
'总览与只读': 'Overview & Read-only',
|
||||
'路由信息': 'Routing Info',
|
||||
'IPv6 状态': 'IPv6 Status',
|
||||
'镜像列表': 'Image List',
|
||||
'查看容器': 'View Container',
|
||||
'开关机/重启': 'Power / Restart',
|
||||
'重装系统': 'Reinstall OS',
|
||||
'资源/到期': 'Resources / Expiration',
|
||||
'流量管理': 'Traffic Management',
|
||||
'端口映射': 'Port Mappings',
|
||||
'分配 IPv6': 'Assign IPv6',
|
||||
'快照与终端': 'Snapshots & Terminal',
|
||||
'查看快照': 'View Snapshots',
|
||||
'创建快照': 'Create Snapshot',
|
||||
'恢复快照': 'Restore Snapshot',
|
||||
'计划/配额': 'Schedule / Quota',
|
||||
'平台管理': 'Platform Management',
|
||||
'下载镜像': 'Download Image',
|
||||
'启停镜像': 'Enable / Disable Image',
|
||||
'安全数据': 'Security Data',
|
||||
'安全扫描': 'Security Scan',
|
||||
'安全设置': 'Security Settings',
|
||||
'Swap 信息': 'Swap Info',
|
||||
'Swap 管理': 'Swap Management',
|
||||
'Key 列表': 'Key List',
|
||||
'创建 Key': 'Create Key',
|
||||
'更新 Key': 'Update Key',
|
||||
'删除 Key': 'Delete Key',
|
||||
'总览': 'Overview',
|
||||
'NAT/IPv6 路由': 'NAT / IPv6 Routing',
|
||||
'任务队列': 'Task Queue',
|
||||
'任务列表': 'Task List',
|
||||
'操作记录': 'audit records',
|
||||
'子用户列表': 'Sub-user List',
|
||||
'创建子用户': 'Create Sub-user',
|
||||
'更新子用户': 'Update Sub-user',
|
||||
'管理员接口': 'Admin API',
|
||||
'控制面板统计': 'Dashboard Stats',
|
||||
'立即安全检查': 'Run Security Check',
|
||||
'返回响应样例': 'Response Example',
|
||||
'请求参数': 'Request Parameters',
|
||||
'响应字段': 'Response Fields',
|
||||
'接口地址': 'Endpoint',
|
||||
'请求方法': 'Method',
|
||||
'权限范围': 'Scopes',
|
||||
'绑定容器': 'Bound Containers',
|
||||
'全部容器': 'All Containers',
|
||||
'全权限': 'Full Access',
|
||||
'取消全权限': 'Remove Full Access',
|
||||
'禁用这个 Key': 'Disable this key',
|
||||
'过期时间': 'Expiration Time',
|
||||
'永不过期': 'Never expires',
|
||||
'IP 白名单': 'IP Whitelist',
|
||||
'密钥名称': 'Key Name',
|
||||
'删除任务': 'Delete Task',
|
||||
'容器详情': 'Container Details',
|
||||
'资源用量': 'Resource Usage',
|
||||
'流量统计': 'Traffic Stats',
|
||||
'重置流量': 'Reset Traffic',
|
||||
'调整流量限制': 'Adjust Traffic Limit',
|
||||
'调整资源限制': 'Adjust Resource Limit',
|
||||
'重置 SSH 密码': 'Reset SSH Password',
|
||||
'端口与快照': 'Ports & Snapshots',
|
||||
'随机可用端口': 'Random Available Port',
|
||||
'添加端口映射': 'Add Port Mapping',
|
||||
'更新端口映射': 'Update Port Mapping',
|
||||
'删除端口映射': 'Delete Port Mapping',
|
||||
'快照总览': 'Snapshot Overview',
|
||||
'容器快照': 'Container Snapshots',
|
||||
'计划快照': 'Scheduled Snapshots',
|
||||
'快照配额': 'Snapshot Quota',
|
||||
'模板列表': 'Template List',
|
||||
'取消镜像下载': 'Cancel Image Download',
|
||||
'启用/禁用镜像': 'Enable / Disable Image',
|
||||
'安全连接日志': 'Security Connection Logs',
|
||||
'安全汇总': 'Security Summary',
|
||||
'更新安全设置': 'Update Security Settings',
|
||||
'调整 Swap': 'Adjust Swap',
|
||||
'批量开关机/删除/重装': 'Batch power/delete/reinstall',
|
||||
'账号与日志': 'Account & Logs',
|
||||
'API Key 列表': 'API Key List',
|
||||
'创建 API Key': 'Create API Key',
|
||||
'更新 API Key': 'Update API Key',
|
||||
'删除 API Key': 'Delete API Key',
|
||||
'30分钟': '30 minutes',
|
||||
'1小时': '1 hour',
|
||||
'1天': '1 day',
|
||||
'切换中文': 'Switch to Chinese',
|
||||
'WebSSH ticket 创建失败,请重新登录后再试': 'Failed to create WebSSH ticket. Log in again and retry.',
|
||||
'WebSSH ticket 为空,请重新登录后再试': 'WebSSH ticket is empty. Log in again and retry.',
|
||||
'WebSSH 连接失败,请确认容器已运行且 SSH 服务可用': 'WebSSH connection failed. Make sure the container is running and SSH is available.',
|
||||
'WebVNC ticket 创建失败,请重新登录后再试': 'Failed to create WebVNC ticket. Log in again and retry.',
|
||||
'WebVNC ticket 为空,请重新登录后再试': 'WebVNC ticket is empty. Log in again and retry.',
|
||||
'WebVNC 连接已断开,请确认虚拟机正在运行且 VNC 控制台可用': 'WebVNC disconnected. Make sure the VM is running and the VNC console is available.',
|
||||
'VNC 安全协商失败': 'VNC security negotiation failed',
|
||||
'当前 VNC 控制台要求密码,暂不支持自动输入': 'This VNC console requires a password. Automatic input is not supported yet.',
|
||||
'删除容器': 'Delete Container',
|
||||
'WebSSH 票据': 'WebSSH Ticket',
|
||||
'WebVNC 票据': 'WebVNC Ticket',
|
||||
'容器列表(兼容旧接口)': 'Container List (legacy-compatible API)',
|
||||
'调整到期时间': 'Adjust Expiration Time',
|
||||
'镜像管理列表': 'Image Management List',
|
||||
'批量创建容器': 'Batch Create Containers',
|
||||
'创建 WebSSH 票据': 'Create WebSSH Ticket',
|
||||
'创建 WebVNC 票据': 'Create WebVNC Ticket',
|
||||
'创建子用户链接': 'Create Sub-user Link',
|
||||
'轮换子用户密码': 'Rotate Sub-user Password',
|
||||
'子用户操作日志': 'Sub-user Audit Logs',
|
||||
'子用户登录日志': 'Sub-user Login Logs',
|
||||
'确定删除这个 API Key 吗?': 'Delete this API Key?',
|
||||
'管理外部调用凭据、权限范围与平台 API 文档': 'Manage external credentials, permission scopes, and platform API docs',
|
||||
'新的 API Key 已生成': 'New API Key generated',
|
||||
'已复制': 'Copied',
|
||||
'加载中...': 'Loading...',
|
||||
'暂无 API Key': 'No API Keys',
|
||||
'权限': 'Permissions',
|
||||
'限制': 'Limits',
|
||||
'最后使用': 'Last Used',
|
||||
'已禁用': 'Disabled',
|
||||
'不限 IP': 'Any IP',
|
||||
'从未使用': 'Never used',
|
||||
'API 文档': 'API Docs',
|
||||
'查看使用范例': 'View examples',
|
||||
'Python 使用范例': 'Python example',
|
||||
'编辑 API Key': 'Edit API Key',
|
||||
'CI/CD、计费系统、自动化脚本': 'CI/CD, billing systems, automation scripts',
|
||||
'SWAP 已调整为 16384 MB': 'SWAP adjusted to 16384 MB',
|
||||
'***60秒有效票据***': '***60-second valid ticket***',
|
||||
'WebVNC 仅适用于 KVM 虚拟机;LXC 容器会返回 VNC console is only available for KVM VMs。': 'WebVNC only applies to KVM VMs; LXC containers return "VNC console is only available for KVM VMs".',
|
||||
'该接口会进入任务队列,请随后调用 GET /api/v1/tasks 查看执行状态。': 'This API enters the task queue. Call GET /api/v1/tasks afterward to check execution status.',
|
||||
'样例中的密钥、密码和票据已脱敏;创建类接口的完整密钥只在创建响应中出现一次。': 'Keys, passwords, and tickets in examples are masked. Full secrets from create APIs appear only once in the creation response.',
|
||||
'编辑月流量限制': 'Edit Monthly Traffic Limit',
|
||||
'流量统计模式': 'Traffic Accounting Mode',
|
||||
'双向合并统计': 'Combined In+Out',
|
||||
'入站/出站分开统计': 'Separate Inbound/Outbound',
|
||||
'月流量上限 (GB,0=不限制)': 'Monthly traffic limit (GB, 0=unlimited)',
|
||||
'入站上限 (GB,0=不限制)': 'Inbound limit (GB, 0=unlimited)',
|
||||
'出站上限 (GB,0=不限制)': 'Outbound limit (GB, 0=unlimited)',
|
||||
'请输入 8-64 位,至少包含字母和数字': 'Enter 8-64 characters, including at least letters and numbers',
|
||||
'Linux LXC/KVM 修改 root SSH 密码通常无需重启;KVM 需要虚拟机运行且 guest agent 或 SSH 可用。': 'Changing the root SSH password for Linux LXC/KVM usually does not require a restart. KVM requires the VM to be running and guest agent or SSH to be available.',
|
||||
'退出全屏': 'Exit Fullscreen',
|
||||
'全屏显示': 'Fullscreen',
|
||||
'全屏': 'Fullscreen',
|
||||
'修改': 'Change',
|
||||
'关闭定时': 'Disable Schedule',
|
||||
'执行时间': 'Run Time',
|
||||
'NAT 端口管理': 'NAT Port Management',
|
||||
'添加映射': 'Add Mapping',
|
||||
'端口配额:': 'Port quota:',
|
||||
'已达到管理员分配的 NAT 端口配额': 'The NAT port quota assigned by the administrator has been reached',
|
||||
'修改端口映射': 'Edit Port Mapping',
|
||||
'重装系统会删除容器内所有数据,请谨慎操作。': 'Reinstalling the OS will delete all data in the container. Proceed carefully.',
|
||||
'选择新系统模板': 'Select New System Template',
|
||||
'确认重装': 'Confirm Reinstall',
|
||||
'当前:': 'Current:',
|
||||
'新到期日期(留空为长期有效)': 'New expiration date (leave blank for no expiration)',
|
||||
'vCPU 核数': 'vCPU Cores',
|
||||
'网络速率 (Mbps,0=不限制)': 'Network speed (Mbps, 0=unlimited)',
|
||||
'IO 速度 (MB/s,0=不限制)': 'IO speed (MB/s, 0=unlimited)',
|
||||
'磁盘容量不支持动态修改。修改后运行中的容器会立即应用新的 cgroup 限制。': 'Disk capacity cannot be changed dynamically. Running containers apply the new cgroup limits immediately.',
|
||||
'恢复': 'Restore',
|
||||
'全部 (ALL)': 'All (ALL)',
|
||||
'外部端口': 'External Port',
|
||||
'默认同内部': 'Same as internal by default',
|
||||
'随机空闲端口': 'Random Free Port',
|
||||
'随机': 'Random',
|
||||
'内部端口': 'Internal Port',
|
||||
'例如 80': 'e.g. 80',
|
||||
'暂无端口映射': 'No port mappings',
|
||||
'默认 SSH 映射不能删除': 'Default SSH mapping cannot be deleted',
|
||||
'入站 (RX)': 'Inbound (RX)',
|
||||
'(不限制)': '(unlimited)',
|
||||
'出站 (TX)': 'Outbound (TX)',
|
||||
'已用': 'Used',
|
||||
'重置': 'Reset',
|
||||
'执行中...': 'Running...',
|
||||
'点击': 'Click',
|
||||
'开始': 'Start',
|
||||
'没有匹配的容器': 'No matching containers',
|
||||
'显示': 'Showing',
|
||||
'初始化失败': 'Initialization failed',
|
||||
'初始化完成': 'Initialization complete',
|
||||
'排队等待': 'Queued',
|
||||
'处理中': 'Processing',
|
||||
'未知系统': 'Unknown system',
|
||||
'处理失败': 'Failed',
|
||||
'暂无任务': 'No tasks',
|
||||
'取消任务': 'Cancel Task',
|
||||
'硬件、网络、磁盘健康与运行环境探测报告': 'Hardware, network, disk health, and runtime environment report',
|
||||
'运行状态': 'Runtime Status',
|
||||
'未检测到内存条明细,可能缺少 dmidecode 或权限受限': 'No memory module details detected. dmidecode may be missing or permissions may be limited.',
|
||||
'插槽': 'Slot',
|
||||
'频率': 'Frequency',
|
||||
'厂商': 'Vendor',
|
||||
'型号/序列号': 'Model / Serial',
|
||||
'未检测到硬盘': 'No disks detected',
|
||||
'型号': 'Model',
|
||||
'挂载点': 'Mount Point',
|
||||
'寿命': 'Lifetime',
|
||||
'通电': 'Power-on',
|
||||
'读取': 'Reads',
|
||||
'写入': 'Writes',
|
||||
'命令数': 'Commands',
|
||||
'擦写': 'Erase Count',
|
||||
'未检测到网卡': 'No network interfaces detected',
|
||||
'未检测到显卡': 'No GPUs detected',
|
||||
'驱动': 'Driver',
|
||||
'管理 LXC / KVM 系统镜像,下载后的镜像才能用于创建容器/虚拟机。': 'Manage LXC / KVM system images. Downloaded images can be used to create containers/VMs.',
|
||||
'已下载': 'Downloaded',
|
||||
'LXC 容器镜像': 'LXC Container Images',
|
||||
'KVM 虚拟机镜像': 'KVM VM Images',
|
||||
'发行版': 'Distribution',
|
||||
'架构': 'Architecture',
|
||||
'禁用': 'Disable',
|
||||
'可用': 'Available',
|
||||
'未下载': 'Not downloaded',
|
||||
'中文': 'Chinese',
|
||||
'宿主机分配给 LXC 的 NAT4 端口和 IPv6 地址': 'NAT4 ports and IPv6 addresses assigned to LXC by the host',
|
||||
'确认删除容器': 'Delete container',
|
||||
'剩余地址 / 地址总数 ·': 'Available Addresses / Total Addresses ·',
|
||||
'结果 ': 'Result ',
|
||||
'告警列表 (': 'Alert List (',
|
||||
'当前证书:': 'Current certificate:',
|
||||
'到期时间:': 'Expires:',
|
||||
'证书路径:': 'Certificate path:',
|
||||
'最近错误:': 'Last error:',
|
||||
'1 天': '1 day',
|
||||
'3 天': '3 days',
|
||||
'7 天': '7 days',
|
||||
'14 天': '14 days',
|
||||
'10 / 页': '10 / page',
|
||||
'20 / 页': '20 / page',
|
||||
'50 / 页': '50 / page',
|
||||
'全局快照列表,共': 'Global snapshot list, total',
|
||||
'容器分配的子用户列表,共': 'Sub-user list assigned to containers, total',
|
||||
}
|
||||
|
||||
const artifactPatterns: RegExp[] = [
|
||||
/Back\s*列表/,
|
||||
/SearchName、ID、UUID、IP/,
|
||||
/All(Type|Status|系统)/,
|
||||
/AutoStop\s*已[开关]/,
|
||||
/暂\s*(None|无)\s*Security Alerts/,
|
||||
/Memory\s*使用/,
|
||||
/网络\s*Traffic/,
|
||||
/实时\s*Status/,
|
||||
/Create\s*Time/,
|
||||
/长期\s*Valid/,
|
||||
]
|
||||
|
||||
const replacements: Array<[RegExp, string]> = [
|
||||
[/Back\s*列表/g, 'Back to list'],
|
||||
[/Search\s*名称、ID、UUID、IP/g, 'Search name, ID, UUID, IP'],
|
||||
[/All\s*类型/g, 'All types'],
|
||||
[/All\s*系统/g, 'All systems'],
|
||||
[/All\s*状态/g, 'All statuses'],
|
||||
[/AllType/g, 'All types'],
|
||||
[/All系统/g, 'All systems'],
|
||||
[/AllStatus/g, 'All statuses'],
|
||||
[/SearchName、ID、UUID、IP/g, 'Search name, ID, UUID, IP'],
|
||||
[/AutoStop\s*已关/g, 'Auto-stop off'],
|
||||
[/AutoStop\s*已开/g, 'Auto-stop on'],
|
||||
[/暂\s*None\s*Security Alerts/g, 'No security alerts'],
|
||||
[/暂\s*无\s*Security Alerts/g, 'No security alerts'],
|
||||
[/WebVNC\s*初始化失败(.+)$/g, 'WebVNC initialization failed$1'],
|
||||
[/确定要删除容器\s*(.+?)\s*吗?此操作不可撤销。/g, 'Delete container $1? This action cannot be undone.'],
|
||||
[/确定要删除容器\s*(.+?)\s*吗?此操作不可撤销。/g, 'Delete container $1? This action cannot be undone.'],
|
||||
[/拍摄快照需要先关机,完成后会自动重启容器\s*(.+?)。是否继续?/g, 'Taking a snapshot requires shutdown first. Container $1 will restart automatically afterward. Continue?'],
|
||||
[/确定删除\s*(.+?)\s*的快照吗?/g, 'Delete snapshot $1?'],
|
||||
[/确定恢复到\s*(.+?)\s*的快照吗?当前容器数据会被覆盖。/g, 'Restore to snapshot $1? Current container data will be overwritten.'],
|
||||
[/旧版\s*\/api\/containers\/list\s*已兼容,但新接入请使用\s*GET\s*\/api\/v1\/containers/g, 'Legacy /api/containers/list remains compatible, but new integrations should use GET /api/v1/containers'],
|
||||
[/到期\s*(.+)$/g, 'Expires $1'],
|
||||
[/支持\s*\((.+?)\)/g, 'Supported ($1)'],
|
||||
[/下载中\s*(.+)$/g, 'Downloading $1'],
|
||||
[/结果\s*(.+)$/g, 'Result $1'],
|
||||
[/磨损\s*(.+)$/g, 'Wear $1'],
|
||||
[/擦写\s*(.+)$/g, 'Erase $1'],
|
||||
[/启停\s*(.+)$/g, 'Power cycles $1'],
|
||||
[/每\s*(.+)$/g, 'Every $1'],
|
||||
[/已开启,每\s*(.+)$/g, 'Enabled, every $1'],
|
||||
[/告警列表\s*\((\d+)\)/g, 'Alert List ($1)'],
|
||||
[/共\s*(\d+)\s*个\s*Container/g, 'Total $1 containers'],
|
||||
[/共\s*(\d+)\s*个\s*容器/g, 'Total $1 containers'],
|
||||
[/共\s*(\d+)\s*条/g, 'Total $1'],
|
||||
[/共\s*(\d+)\s*个/g, 'Total $1 items'],
|
||||
[/,筛选后\s*(\d+)\s*个/g, ', filtered $1 items'],
|
||||
[/,已选\s*(\d+)\s*个/g, ', selected $1 items'],
|
||||
[/第\s*(\d+)\/(\d+)\s*页/g, 'Page $1/$2'],
|
||||
[/显示\s*(\d+)-(\d+)\s*\/\s*(\d+)/g, 'Showing $1-$2 / $3'],
|
||||
[/显示\s*(\d+)-(\d+),共\s*(\d+)\s*条/g, 'Showing $1-$2 of $3'],
|
||||
[/搜索\s*"([^"]+)"\s*结果\s*(\d+)\s*条,/g, 'Search "$1" returned $2 results, '],
|
||||
[/搜索\s*"([^"]+)"\s*结果\s*(\d+)\s*个地址/g, 'Search "$1" returned $2 addresses, '],
|
||||
[/(\d+)\s*个/g, '$1 items'],
|
||||
[/(\d+)\s*条/g, '$1 records'],
|
||||
[/(\d+)\s*核/g, '$1 cores'],
|
||||
[/(\d+)\s*线程/g, '$1 threads'],
|
||||
[/已用/g, 'used'],
|
||||
[/未设置\s*Traffic\s*限制/g, 'No traffic limit set'],
|
||||
[/Memory\s*使用/g, 'Memory Usage'],
|
||||
[/网络\s*Traffic/g, 'Network Traffic'],
|
||||
[/实时\s*Status/g, 'Live Status'],
|
||||
[/Expiration Time\s*长期\s*Valid/g, 'Expiration Time No expiration'],
|
||||
[/长期\s*Valid/g, 'No expiration'],
|
||||
[/Create\s*Time/g, 'Created At'],
|
||||
[/CPU\s*累计\s*Time/g, 'CPU Total Time'],
|
||||
[/(\d+(?:\.\d+)?)\s*cores\s*\/\s*(\d+)\s*核/g, '$1 cores / $2 cores'],
|
||||
[/(\d+)\s*核\/(.+?)\/(\d+)\s*GB/g, '$1 cores / $2 / $3 GB'],
|
||||
[/(\d+)\s*\/\s*页/g, '$1 / page'],
|
||||
[/到期时间:/g, 'Expires: '],
|
||||
[/证书路径:/g, 'Certificate path: '],
|
||||
[/最近错误:/g, 'Last error: '],
|
||||
[/当前证书:/g, 'Current certificate: '],
|
||||
[/第\s*(\d+)\s*页/g, 'Page $1'],
|
||||
[/入\s*([^/,]+)\s*\/\s*出\s*([^,]+),累计\s*(.+)$/g, 'In $1 / Out $2, total $3'],
|
||||
[/读\s*([^/,]+)\s*\/\s*写\s*([^,]+),累计\s*([^,]+),容量\s*(.+)$/g, 'Read $1 / Write $2, total $3, capacity $4'],
|
||||
[/(.+?),筛选后\s*(\d+)\s*items/g, '$1, filtered $2 items'],
|
||||
[/(.+?),已选\s*(\d+)\s*items/g, '$1, selected $2 items'],
|
||||
[/将创建\s*(\d+)\s*个容器:(.+?)\s*至\s*(.+)$/g, 'Will create $1 containers: $2 to $3'],
|
||||
[/暂无可用的\s*(KVM|LXC)\s*系统镜像,请先在「镜像管理」中下载镜像模板。/g, 'No available $1 system images. Download image templates in Images first.'],
|
||||
[/不能小于\s*(.+)$/g, 'Cannot be less than $1'],
|
||||
[/不能大于\s*(.+)$/g, 'Cannot be greater than $1'],
|
||||
[/^(.+?)\s*-\s*操作日志$/g, '$1 - Audit Logs'],
|
||||
[/^(.+?)\s*-\s*登录日志$/g, '$1 - Login Logs'],
|
||||
[/^(.+?)。下次登录生效$/g, '$1. Takes effect at next login'],
|
||||
[/阶段:(.+)$/g, 'Stage: $1'],
|
||||
[/\$\{days\}天/g, '${days} days'],
|
||||
[/\$\{hours\}小时/g, '${hours} hours'],
|
||||
[/\$\{hours\}\s*小时/g, '${hours} hours'],
|
||||
[/\$\{Math\.floor\(diff \/ 60000\)\}分钟/g, '${Math.floor(diff / 60000)} minutes'],
|
||||
[/(\d+)分钟/g, '$1 minutes'],
|
||||
[/(\d+)小时/g, '$1 hours'],
|
||||
[/(\d+)\s*周/g, '$1 weeks'],
|
||||
[/(\d+)天/g, '$1 days'],
|
||||
[/确认删除容器\s*(.+?)\s*的快照吗?此操作不可恢复。/g, 'Delete the snapshot for container $1? This cannot be undone.'],
|
||||
[/确定要删除容器\s*(.+?)\s*吗?此操作不可撤销。/g, 'Delete container $1? This action cannot be undone.'],
|
||||
[/容器\s*(.+?)\s*已开机/g, 'Container $1 started'],
|
||||
[/容器\s*(.+?)\s*已关机/g, 'Container $1 stopped'],
|
||||
[/容器\s*(.+?)\s*已重启/g, 'Container $1 restarted'],
|
||||
]
|
||||
|
||||
export function translateText(value: string): string {
|
||||
if (!shouldTranslateText(value)) return value
|
||||
const leading = value.match(/^\s*/)?.[0] || ''
|
||||
const trailing = value.match(/\s*$/)?.[0] || ''
|
||||
const body = value.trim()
|
||||
if (!body) return value
|
||||
if (exact[body]) return leading + exact[body] + trailing
|
||||
let translated = body
|
||||
for (const [pattern, replacement] of replacements) {
|
||||
translated = translated.replace(pattern, replacement)
|
||||
}
|
||||
for (const [source, target] of Object.entries(exact).sort((a, b) => b[0].length - a[0].length)) {
|
||||
translated = translated.split(source).join(target)
|
||||
}
|
||||
translated = cleanupTranslatedText(translated)
|
||||
return leading + translated + trailing
|
||||
}
|
||||
|
||||
export function shouldTranslateText(value: string): boolean {
|
||||
return /[\u3400-\u9fff]/.test(value) || artifactPatterns.some((pattern) => pattern.test(value))
|
||||
}
|
||||
|
||||
function cleanupTranslatedText(value: string): string {
|
||||
return value
|
||||
.replace(/Back\s*List/g, 'Back to list')
|
||||
.replace(/Container\s*List/g, 'Container List')
|
||||
.replace(/Snapshot\s*List/g, 'Snapshot List')
|
||||
.replace(/All\s*Type/g, 'All types')
|
||||
.replace(/All\s*Status/g, 'All statuses')
|
||||
.replace(/All\s*System/g, 'All systems')
|
||||
.replace(/AutoStop\s*Off/g, 'Auto-stop off')
|
||||
.replace(/AutoStop\s*On/g, 'Auto-stop on')
|
||||
.replace(/\s{2,}/g, ' ')
|
||||
}
|
||||
+754
-67
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user