Compare commits

...

22 Commits

Author SHA1 Message Date
MengMengCode a923daa7a2 release: v1.1.7 2026-06-09 13:06:46 +08:00
MengMengCode c46f84c66e 增加SSL支持HTTPS/WSS 2026-06-09 13:06:26 +08:00
MengMengCode 835bb51c6e 修改 2026-06-09 02:00:04 +08:00
MengMengCode 7aed51e86b gitignore优化 2026-06-09 01:59:54 +08:00
MengMengCode e364807fb9 docs 2026-06-09 00:49:53 +08:00
MengMengCode c63ce02709 docs 2026-06-09 00:44:00 +08:00
MengMengCode 54f9ed7f7d Merge branch 'main' of https://github.com/MengMengCode/CLICD 2026-06-09 00:25:06 +08:00
MengMengCode b01f9fe301 修复UI 2026-06-09 00:25:04 +08:00
Meng Meng d03e2c4c0c Create LICENSE 2026-06-09 00:23:55 +08:00
MengMengCode 33603f5776 release: v1.1.6 2026-06-09 00:12:19 +08:00
MengMengCode 9ad7bcc97a 完善API文档 2026-06-09 00:10:45 +08:00
MengMengCode f3a1687a18 修复了一些已知问题 2026-06-08 21:18:17 +08:00
MengMengCode 49b13af91c release: v1.1.5 2026-06-08 20:10:34 +08:00
MengMengCode e79609281f 增强API集成能力,划分KEY功能权限 2026-06-08 19:25:55 +08:00
MengMengCode 2fa130a2b6 标记并清理 CLICD 创建的 libvirt default 网络 2026-06-08 16:21:23 +08:00
MengMengCode 14d2192b05 完善卸载网络规则清理 2026-06-08 16:18:24 +08:00
MengMengCode 9f5ad94a83 清理卸载时的 LXC 镜像缓存 2026-06-08 16:10:55 +08:00
MengMengCode ac6587f2bc 增强安装脚本发行版下载回退 2026-06-08 16:03:32 +08:00
MengMengCode 6fad37b844 修复安装脚本下载失败处理 2026-06-08 15:55:17 +08:00
MengMengCode d0eb92eaab 修复 2026-06-08 15:49:42 +08:00
MengMengCode 5207082cd1 release: v1.1.4 2026-06-08 15:44:58 +08:00
MengMengCode 608b50f18a 修复了一些功能 2026-06-08 15:44:35 +08:00
72 changed files with 9694 additions and 755 deletions
Submodule .claude/worktrees/agent-ae3871aebda20eb86 deleted from 422e48b524
+65
View File
@@ -0,0 +1,65 @@
name: Deploy Docs
on:
push:
branches:
- main
- master
paths:
- "docs/**"
- ".github/workflows/pages.yml"
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: github-pages
cancel-in-progress: false
jobs:
build:
name: Build VitePress
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
cache: npm
cache-dependency-path: docs/package-lock.json
- name: Setup Pages
uses: actions/configure-pages@v5
- name: Install dependencies
working-directory: docs
run: npm ci
- name: Build docs
working-directory: docs
env:
VITEPRESS_BASE: /
run: npm run build
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: docs/.vitepress/dist
deploy:
name: Deploy GitHub Pages
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
+1 -1
View File
@@ -58,7 +58,7 @@ backend/tmp/
*.swp
*.swo
*~
*.claude/
.claude/
# OS
.DS_Store
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+272 -106
View File
@@ -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)
}
+165 -4
View File
@@ -1,6 +1,7 @@
package api
import (
"context"
"encoding/json"
"net/http"
"strings"
@@ -28,6 +29,132 @@ type APIResponse struct {
Data interface{} `json:"data,omitempty"`
}
type authContextKey struct{}
type AuthContext struct {
Type string
Username string
ApiKeyID string
ApiKeyName string
Actor string
Scopes []string
ContainerUUIDs []string
}
const (
authTypeAdmin = "admin"
authTypeSubUser = "sub_user"
authTypeAPIKey = "api_key"
)
func withAuthContext(r *http.Request, auth AuthContext) *http.Request {
return r.WithContext(context.WithValue(r.Context(), authContextKey{}, auth))
}
func authContextFromRequest(r *http.Request) (AuthContext, bool) {
ctx, ok := r.Context().Value(authContextKey{}).(AuthContext)
return ctx, ok
}
func requestActor(r *http.Request) string {
if ctx, ok := authContextFromRequest(r); ok && ctx.Actor != "" {
return ctx.Actor
}
if claims, ok := claimsFromRequest(r); ok {
if subUser, _ := claims["sub_user"].(string); subUser != "" {
return "user:" + subUser
}
if username, _ := claims["username"].(string); username != "" {
return username
}
}
return "admin"
}
func hasScope(r *http.Request, scope string) bool {
ctx, ok := authContextFromRequest(r)
if !ok {
return true
}
switch ctx.Type {
case authTypeAdmin:
return true
case authTypeSubUser:
return subUserScopeAllowed(scope)
case authTypeAPIKey:
return scopeAllowed(ctx.Scopes, scope)
default:
return false
}
}
func subUserScopeAllowed(scope string) bool {
switch scope {
case "container:read", "container:power", "container:reinstall", "container:network",
"dashboard:read", "image:read", "task:read", "snapshot:read", "snapshot:create", "snapshot:delete", "snapshot:restore", "snapshot:schedule",
"terminal:ssh", "terminal:vnc":
return true
default:
return false
}
}
func hasAnyScope(r *http.Request, scopes ...string) bool {
for _, scope := range scopes {
if hasScope(r, scope) {
return true
}
}
return false
}
func scopeAllowed(scopes []string, required string) bool {
for _, scope := range scopes {
scope = strings.TrimSpace(scope)
if scope == "*" || scope == "admin:*" || scope == required {
return true
}
if strings.HasSuffix(scope, ":*") {
prefix := strings.TrimSuffix(scope, "*")
if strings.HasPrefix(required, prefix) {
return true
}
}
}
return false
}
func requireScope(w http.ResponseWriter, r *http.Request, scope string) bool {
if hasScope(r, scope) {
return true
}
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Insufficient API key scope"})
return false
}
func ScopeMiddleware(scope string, next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !requireScope(w, r, scope) {
return
}
next(w, r)
}
}
func AnyScopeMiddleware(scopes []string, next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if hasAnyScope(r, scopes...) {
next(w, r)
return
}
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Insufficient API key scope"})
}
}
func auditRequest(r *http.Request, action, target, detail string, success bool, errMsg string) {
config.AddAuditLogFull(action, target, detail, requestActor(r), clientIP(r), r.UserAgent(), success, errMsg)
}
func jsonResponse(w http.ResponseWriter, status int, resp APIResponse) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
@@ -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
}
+21
View File
@@ -0,0 +1,21 @@
//go:build linux
package api
import "golang.org/x/sys/unix"
func getRootDiskInfo() (DiskInfo, bool) {
var stat unix.Statfs_t
if err := unix.Statfs("/", &stat); err != nil {
return DiskInfo{}, false
}
total := float64(int64(stat.Blocks)*int64(stat.Bsize)) / (1024 * 1024 * 1024)
free := float64(int64(stat.Bavail)*int64(stat.Bsize)) / (1024 * 1024 * 1024)
return DiskInfo{
TotalGB: total,
UsedGB: total - free,
FreeGB: free,
}, true
}
+99 -1
View File
@@ -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})
}
File diff suppressed because it is too large Load Diff
+18
View File
@@ -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()
+3
View File
@@ -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})
}
+3
View File
@@ -50,6 +50,9 @@ func HandleRouting(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
if !requireScope(w, r, "routing:read") {
return
}
nat4Mappings := make([]nat4Route, 0)
usedPorts := map[int]bool{}
+43 -2
View File
@@ -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()
+3
View File
@@ -56,6 +56,9 @@ func HandleLoginLogs(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
if !requireScope(w, r, "loginlog:read") {
return
}
// Return in reverse (newest first)
reversed := make([]LoginLog, len(loginLogs))
+37 -9
View File
@@ -16,7 +16,11 @@ func HandleSnapshots(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
if !requireScope(w, r, "snapshot:read") {
return
}
snapshots := append([]config.Snapshot(nil), config.AppConfig.Snapshots...)
snapshots = filterSnapshotsForRequest(r, snapshots)
sortSnapshotsNewestFirst(snapshots)
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: snapshots})
}
@@ -24,17 +28,35 @@ func HandleSnapshots(w http.ResponseWriter, r *http.Request) {
func handleContainerSnapshots(w http.ResponseWriter, r *http.Request, containerID int, action string) {
switch {
case action == "snapshots" && r.Method == http.MethodGet:
if !requireScope(w, r, "snapshot:read") {
return
}
listContainerSnapshots(w, r, containerID)
case action == "snapshots" && r.Method == http.MethodPost:
if !requireScope(w, r, "snapshot:create") {
return
}
createContainerSnapshot(w, r, containerID)
case action == "snapshots/schedule" && r.Method == http.MethodPost:
if !requireScope(w, r, "snapshot:schedule") {
return
}
updateSnapshotSchedule(w, r, containerID)
case action == "snapshots/quota" && r.Method == http.MethodPut:
if !requireScope(w, r, "snapshot:schedule") {
return
}
updateSnapshotQuota(w, r, containerID)
case strings.HasPrefix(action, "snapshots/") && strings.HasSuffix(action, "/restore") && r.Method == http.MethodPost:
if !requireScope(w, r, "snapshot:restore") {
return
}
snapshotID := strings.TrimSuffix(strings.TrimPrefix(action, "snapshots/"), "/restore")
restoreContainerSnapshot(w, r, containerID, snapshotID)
case strings.HasPrefix(action, "snapshots/") && r.Method == http.MethodDelete:
if !requireScope(w, r, "snapshot:delete") {
return
}
snapshotID := strings.TrimPrefix(action, "snapshots/")
deleteContainerSnapshot(w, r, containerID, snapshotID)
default:
@@ -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
}
+3
View File
@@ -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"`
}
+580
View File
@@ -0,0 +1,580 @@
package api
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"math/big"
"net"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"clicd/internal/config"
)
type sslSettingsRequest struct {
Enabled bool `json:"enabled"`
Mode string `json:"mode"`
Target string `json:"target"`
Email string `json:"email"`
CertPEM string `json:"cert_pem"`
KeyPEM string `json:"key_pem"`
ApplyNow bool `json:"apply_now"`
}
type sslCertificateInfo struct {
Subject string `json:"subject"`
Issuer string `json:"issuer"`
DNSNames []string `json:"dns_names"`
IPNames []string `json:"ip_names"`
NotBefore string `json:"not_before"`
NotAfter string `json:"not_after"`
Valid bool `json:"valid"`
}
type sslSavedCertificateStatus struct {
config.SSLConfig
Certificate *sslCertificateInfo `json:"certificate,omitempty"`
}
type sslSettingsResponse struct {
config.SSLConfig
DetectedHost string `json:"detected_host"`
Certificate *sslCertificateInfo `json:"certificate,omitempty"`
ModeCertificates map[string]sslSavedCertificateStatus `json:"mode_certificates"`
NeedsRestart bool `json:"needs_restart,omitempty"`
}
func HandleSSLSettings(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: sslSettingsStatus(r, false)})
case http.MethodPut:
updateSSLSettings(w, r)
default:
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
}
}
func updateSSLSettings(w http.ResponseWriter, r *http.Request) {
var req sslSettingsRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
return
}
mode := config.NormalizeSSLMode(req.Mode)
if !req.Enabled || mode == config.SSLModeDisabled {
saveCurrentSSLSlot()
config.AppConfig.SSL = config.SSLConfig{Enabled: false, Mode: config.SSLModeDisabled}
if err := config.SaveConfig(); err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Save SSL settings failed"})
return
}
restartIfRequested(req.ApplyNow)
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "SSL disabled", Data: sslSettingsStatus(r, true)})
return
}
target := strings.TrimSpace(req.Target)
if target == "" {
target = detectedRequestHost(r)
}
if target == "" {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "SSL target is required"})
return
}
next, err := resolveSSLModeCertificate(mode, target, strings.TrimSpace(req.Email), req.CertPEM, req.KeyPEM)
if err != nil {
_ = config.SaveConfig()
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error(), Data: sslSettingsStatus(r, false)})
return
}
if err := validateCertificatePair(next.CertPath, next.KeyPath); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
return
}
next.LastIssuedAt = time.Now().Format(time.RFC3339)
next.Enabled = true
config.AppConfig.SSL = next
saveSSLSlot(next)
if err := config.SaveConfig(); err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Save SSL settings failed"})
return
}
restartIfRequested(req.ApplyNow)
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "SSL settings saved", Data: sslSettingsStatus(r, true)})
}
func sslSettingsStatus(r *http.Request, needsRestart bool) sslSettingsResponse {
cfg := config.AppConfig.SSL
cfg.KeyPath = maskExistingPath(cfg.KeyPath)
resp := sslSettingsResponse{
SSLConfig: cfg,
DetectedHost: detectedRequestHost(r),
ModeCertificates: sslModeCertificatesStatus(),
NeedsRestart: needsRestart,
}
if cert, err := readCertificateInfo(config.AppConfig.SSL.CertPath); err == nil {
resp.Certificate = cert
}
return resp
}
func resolveSSLModeCertificate(mode, target, email, certPEM, keyPEM string) (config.SSLConfig, error) {
if config.AppConfig.SSLCertificates == nil {
config.AppConfig.SSLCertificates = map[string]config.SSLConfig{}
}
next := config.AppConfig.SSLCertificates[mode]
next.Mode = mode
next.Target = target
if email != "" || next.Email == "" {
next.Email = email
}
var err error
switch mode {
case config.SSLModeUploaded:
if strings.TrimSpace(certPEM) != "" || strings.TrimSpace(keyPEM) != "" {
next.CertPath, next.KeyPath, err = saveUploadedCertificate(certPEM, keyPEM)
} else if next.CertPath == "" || next.KeyPath == "" {
err = fmt.Errorf("certificate and private key are required")
} else if !certificateUsable(next.CertPath, next.KeyPath, target) {
err = fmt.Errorf("uploaded certificate is expired, invalid, or does not match the target")
}
case config.SSLModeSelfSigned:
if !certificateUsable(next.CertPath, next.KeyPath, target) {
next.CertPath, next.KeyPath, err = generateSelfSignedCertificate(target)
}
case config.SSLModeLetsEncrypt:
if !certificateUsable(next.CertPath, next.KeyPath, target) {
next.CertPath, next.KeyPath, err = requestLetsEncryptCertificate(target, next.Email)
}
default:
err = fmt.Errorf("unsupported SSL mode")
}
if err != nil {
next.LastError = err.Error()
saveSSLSlot(next)
return next, err
}
next.LastError = ""
return next, nil
}
func sslModeCertificatesStatus() map[string]sslSavedCertificateStatus {
result := map[string]sslSavedCertificateStatus{}
for _, mode := range []string{config.SSLModeLetsEncrypt, config.SSLModeSelfSigned, config.SSLModeUploaded} {
cfg := config.AppConfig.SSLCertificates[mode]
cfg.KeyPath = maskExistingPath(cfg.KeyPath)
status := sslSavedCertificateStatus{SSLConfig: cfg}
if cert, err := readCertificateInfo(config.AppConfig.SSLCertificates[mode].CertPath); err == nil {
status.Certificate = cert
}
result[mode] = status
}
return result
}
func saveCurrentSSLSlot() {
if config.AppConfig.SSL.Mode == config.SSLModeDisabled || config.AppConfig.SSL.CertPath == "" {
return
}
saveSSLSlot(config.AppConfig.SSL)
}
func saveSSLSlot(ssl config.SSLConfig) {
mode := config.NormalizeSSLMode(ssl.Mode)
if mode == config.SSLModeDisabled {
return
}
if config.AppConfig.SSLCertificates == nil {
config.AppConfig.SSLCertificates = map[string]config.SSLConfig{}
}
ssl.Mode = mode
ssl.Enabled = false
config.AppConfig.SSLCertificates[mode] = ssl
}
func saveUploadedCertificate(certPEM, keyPEM string) (string, string, error) {
certPEM = strings.TrimSpace(certPEM)
keyPEM = strings.TrimSpace(keyPEM)
if certPEM == "" || keyPEM == "" {
return "", "", fmt.Errorf("certificate and private key are required")
}
if _, err := tls.X509KeyPair([]byte(certPEM), []byte(keyPEM)); err != nil {
return "", "", fmt.Errorf("certificate/private key mismatch: %v", err)
}
dir := sslStorageDir()
if err := os.MkdirAll(dir, 0700); err != nil {
return "", "", err
}
certPath := filepath.Join(dir, "uploaded-fullchain.pem")
keyPath := filepath.Join(dir, "uploaded-privkey.pem")
if err := os.WriteFile(certPath, []byte(certPEM+"\n"), 0600); err != nil {
return "", "", err
}
if err := os.WriteFile(keyPath, []byte(keyPEM+"\n"), 0600); err != nil {
return "", "", err
}
return certPath, keyPath, nil
}
func generateSelfSignedCertificate(target string) (string, string, error) {
target = strings.TrimSpace(target)
if target == "" {
return "", "", fmt.Errorf("self-signed certificate target is required")
}
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return "", "", err
}
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
if err != nil {
return "", "", err
}
now := time.Now()
tpl := x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{
CommonName: target,
},
NotBefore: now.Add(-time.Hour),
NotAfter: now.AddDate(1, 0, 0),
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
}
if ip := net.ParseIP(target); ip != nil {
tpl.IPAddresses = []net.IP{ip}
} else {
tpl.DNSNames = []string{target}
}
der, err := x509.CreateCertificate(rand.Reader, &tpl, &tpl, &key.PublicKey, key)
if err != nil {
return "", "", err
}
keyDER, err := x509.MarshalECPrivateKey(key)
if err != nil {
return "", "", err
}
dir := sslStorageDir()
if err := os.MkdirAll(dir, 0700); err != nil {
return "", "", err
}
certPath := filepath.Join(dir, "self-signed-fullchain.pem")
keyPath := filepath.Join(dir, "self-signed-privkey.pem")
certOut := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
keyOut := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
if err := os.WriteFile(certPath, certOut, 0600); err != nil {
return "", "", err
}
if err := os.WriteFile(keyPath, keyOut, 0600); err != nil {
return "", "", err
}
return certPath, keyPath, nil
}
func requestLetsEncryptCertificate(target, email string) (string, string, error) {
if _, err := exec.LookPath("certbot"); err != nil {
return "", "", fmt.Errorf("certbot is not installed on this server")
}
target = strings.TrimSpace(target)
if target == "" {
return "", "", fmt.Errorf("Let's Encrypt target is required")
}
args := []string{"certonly", "--non-interactive", "--agree-tos", "--standalone"}
if email != "" {
args = append(args, "--email", email)
} else {
args = append(args, "--register-unsafely-without-email")
}
if net.ParseIP(target) != nil {
if err := ensureCertbotSupportsIPCertificates(); err != nil {
return "", "", err
}
args = append(args, "--preferred-profile", "shortlived", "--ip-address", target)
} else {
args = append(args, "-d", target)
}
cmd := exec.Command("certbot", args...)
output, err := cmd.CombinedOutput()
if err != nil {
return "", "", fmt.Errorf("Let's Encrypt request failed: %s", strings.TrimSpace(string(output)))
}
certPath := filepath.Join("/etc/letsencrypt/live", target, "fullchain.pem")
keyPath := filepath.Join("/etc/letsencrypt/live", target, "privkey.pem")
if _, err := os.Stat(certPath); err != nil {
return "", "", fmt.Errorf("Let's Encrypt certificate file not found after issuance: %s", certPath)
}
if _, err := os.Stat(keyPath); err != nil {
return "", "", fmt.Errorf("Let's Encrypt private key file not found after issuance: %s", keyPath)
}
return certPath, keyPath, nil
}
func ensureCertbotSupportsIPCertificates() error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "certbot", "--help", "all")
output, err := cmd.CombinedOutput()
if ctx.Err() == context.DeadlineExceeded {
return fmt.Errorf("certbot check timed out")
}
if err != nil {
return fmt.Errorf("certbot capability check failed: %s", strings.TrimSpace(string(output)))
}
help := string(output)
if !strings.Contains(help, "--ip-address") || !strings.Contains(help, "--preferred-profile") {
return fmt.Errorf("current certbot does not support IP certificates; install Certbot 5.4+ from snap or another current source")
}
return nil
}
func validateCertificatePair(certPath, keyPath string) error {
certPEM, err := os.ReadFile(certPath)
if err != nil {
return err
}
keyPEM, err := os.ReadFile(keyPath)
if err != nil {
return err
}
if _, err := tls.X509KeyPair(certPEM, keyPEM); err != nil {
return fmt.Errorf("certificate/private key mismatch: %v", err)
}
return nil
}
func certificateUsable(certPath, keyPath, target string) bool {
if certPath == "" || keyPath == "" {
return false
}
if err := validateCertificatePair(certPath, keyPath); err != nil {
return false
}
cert, err := readLeafCertificate(certPath)
if err != nil {
return false
}
now := time.Now()
if now.Before(cert.NotBefore) || !now.Before(cert.NotAfter) {
return false
}
return certificateMatchesTarget(cert, target)
}
func certificateNeedsRenewal(certPath, keyPath, target string, renewBefore time.Duration) bool {
if certPath == "" || keyPath == "" {
return true
}
if err := validateCertificatePair(certPath, keyPath); err != nil {
return true
}
cert, err := readLeafCertificate(certPath)
if err != nil {
return true
}
now := time.Now()
if now.Before(cert.NotBefore) || !now.Before(cert.NotAfter) {
return true
}
if !certificateMatchesTarget(cert, target) {
return true
}
return cert.NotAfter.Sub(now) <= renewBefore
}
func certificateMatchesTarget(cert *x509.Certificate, target string) bool {
target = strings.TrimSpace(strings.Trim(target, "[]"))
if target == "" {
return true
}
if ip := net.ParseIP(target); ip != nil {
for _, certIP := range cert.IPAddresses {
if certIP.Equal(ip) {
return true
}
}
return false
}
if err := cert.VerifyHostname(target); err != nil {
return false
}
return true
}
func readCertificateInfo(certPath string) (*sslCertificateInfo, error) {
cert, err := readLeafCertificate(certPath)
if err != nil {
return nil, err
}
ipNames := make([]string, 0, len(cert.IPAddresses))
for _, ip := range cert.IPAddresses {
ipNames = append(ipNames, ip.String())
}
return &sslCertificateInfo{
Subject: cert.Subject.String(),
Issuer: cert.Issuer.String(),
DNSNames: cert.DNSNames,
IPNames: ipNames,
NotBefore: cert.NotBefore.Format(time.RFC3339),
NotAfter: cert.NotAfter.Format(time.RFC3339),
Valid: time.Now().After(cert.NotBefore) && time.Now().Before(cert.NotAfter),
}, nil
}
func readLeafCertificate(certPath string) (*x509.Certificate, error) {
if certPath == "" {
return nil, errors.New("certificate path is empty")
}
data, err := os.ReadFile(certPath)
if err != nil {
return nil, err
}
block, _ := pem.Decode(data)
if block == nil {
return nil, errors.New("certificate PEM is invalid")
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return nil, err
}
return cert, nil
}
func detectedRequestHost(r *http.Request) string {
host := strings.TrimSpace(r.Host)
if host == "" {
return firstPublicInterfaceIP()
}
if h, _, err := net.SplitHostPort(host); err == nil {
host = h
}
host = strings.Trim(host, "[]")
if host == "localhost" || net.ParseIP(host).IsLoopback() {
if ip := firstPublicInterfaceIP(); ip != "" {
return ip
}
}
return host
}
func firstPublicInterfaceIP() string {
addrs, err := net.InterfaceAddrs()
if err != nil {
return ""
}
for _, addr := range addrs {
ipNet, ok := addr.(*net.IPNet)
if !ok {
continue
}
ip := ipNet.IP.To4()
if ip == nil || ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() {
continue
}
return ip.String()
}
return ""
}
func sslStorageDir() string {
dataDir := config.AppConfig.DataDir
if dataDir == "" {
dataDir = "/root/.clicd"
}
return filepath.Join(dataDir, "ssl")
}
func maskExistingPath(path string) string {
if path == "" {
return ""
}
return path
}
func restartIfRequested(applyNow bool) {
if !applyNow {
return
}
go func() {
time.Sleep(500 * time.Millisecond)
_ = exec.Command("systemctl", "restart", "clicd").Start()
}()
}
func StartSSLRenewalMonitor() {
go func() {
time.Sleep(30 * time.Second)
renewSavedSSLCertificates()
ticker := time.NewTicker(6 * time.Hour)
defer ticker.Stop()
for range ticker.C {
renewSavedSSLCertificates()
}
}()
}
func renewSavedSSLCertificates() {
if config.AppConfig == nil || len(config.AppConfig.SSLCertificates) == 0 {
return
}
changed := false
for mode, cert := range config.AppConfig.SSLCertificates {
mode = config.NormalizeSSLMode(mode)
if cert.Target == "" || mode == config.SSLModeDisabled || mode == config.SSLModeUploaded {
continue
}
var certPath, keyPath string
var err error
switch mode {
case config.SSLModeLetsEncrypt:
if !certificateNeedsRenewal(cert.CertPath, cert.KeyPath, cert.Target, 48*time.Hour) {
continue
}
certPath, keyPath, err = requestLetsEncryptCertificate(cert.Target, cert.Email)
case config.SSLModeSelfSigned:
if !certificateNeedsRenewal(cert.CertPath, cert.KeyPath, cert.Target, 30*24*time.Hour) {
continue
}
certPath, keyPath, err = generateSelfSignedCertificate(cert.Target)
}
if err != nil {
cert.LastError = err.Error()
config.AppConfig.SSLCertificates[mode] = cert
changed = true
continue
}
cert.CertPath = certPath
cert.KeyPath = keyPath
cert.LastIssuedAt = time.Now().Format(time.RFC3339)
cert.LastError = ""
config.AppConfig.SSLCertificates[mode] = cert
if config.AppConfig.SSL.Enabled && config.AppConfig.SSL.Mode == mode {
active := cert
active.Enabled = true
config.AppConfig.SSL = active
}
changed = true
}
if changed {
_ = config.SaveConfig()
}
}
+95 -27
View File
@@ -49,6 +49,9 @@ func HandleSubUserCreate(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
if !requireScope(w, r, "subuser:create") {
return
}
var req struct {
ContainerName string `json:"container_name"`
@@ -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})
+50 -27
View File
@@ -11,19 +11,27 @@ import (
)
type SwapInfo struct {
TotalMB int64 `json:"total_mb"`
UsedMB int64 `json:"used_mb"`
FreeMB int64 `json:"free_mb"`
Enabled bool `json:"enabled"`
SwapFile string `json:"swap_file"`
TotalMB int64 `json:"total_mb"`
UsedMB int64 `json:"used_mb"`
FreeMB int64 `json:"free_mb"`
Enabled bool `json:"enabled"`
SwapFile string `json:"swap_file"`
}
const (
minSwapSizeMB = 128
maxSwapSizeMB = 262144
)
// HandleSwapInfo returns current swap status
func HandleSwapInfo(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
if !requireScope(w, r, "swap:read") {
return
}
info := getSwapInfo()
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: info})
@@ -35,9 +43,12 @@ func HandleSwapManage(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
if !requireScope(w, r, "swap:manage") {
return
}
var req struct {
Action string `json:"action"` // create, enable, disable, resize
Action string `json:"action"` // create, enable, disable, resize
SizeMB int `json:"size_mb"` // for create/resize
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@@ -46,54 +57,63 @@ func HandleSwapManage(w http.ResponseWriter, r *http.Request) {
}
var msg string
var err error
switch req.Action {
case "create":
if req.SizeMB <= 0 {
req.SizeMB = 2048
}
err := createSwap(req.SizeMB)
if err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
return
if err = validateSwapSize(req.SizeMB); err == nil {
err = createSwap(req.SizeMB)
}
msg = fmt.Sprintf("已创建 %d MB SWAP", req.SizeMB)
case "enable":
err := enableSwap()
if err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
return
}
err = enableSwap()
msg = "SWAP 已启用"
case "disable":
err := disableSwap()
if err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
return
}
err = disableSwap()
msg = "SWAP 已禁用"
case "resize":
if req.SizeMB <= 0 {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid size"})
return
if err = validateSwapSize(req.SizeMB); err == nil {
err = disableSwap()
}
if err == nil {
err = createSwap(req.SizeMB)
}
if err == nil {
err = enableSwap()
}
disableSwap()
createSwap(req.SizeMB)
enableSwap()
msg = fmt.Sprintf("SWAP 已调整为 %d MB", req.SizeMB)
default:
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid action: " + req.Action})
return
}
if err != nil {
auditRequest(r, "swap."+req.Action, "/swapfile", fmt.Sprintf("size_mb=%d", req.SizeMB), false, err.Error())
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
return
}
info := getSwapInfo()
auditRequest(r, "swap."+req.Action, "/swapfile", fmt.Sprintf("size_mb=%d", req.SizeMB), true, "")
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: msg, Data: info})
}
func validateSwapSize(sizeMB int) error {
if sizeMB < minSwapSizeMB {
return fmt.Errorf("swap size must be at least %d MB", minSwapSizeMB)
}
if sizeMB > maxSwapSizeMB {
return fmt.Errorf("swap size cannot exceed %d MB", maxSwapSizeMB)
}
return nil
}
func getSwapInfo() SwapInfo {
info := SwapInfo{SwapFile: "/swapfile"}
@@ -160,6 +180,9 @@ func createSwap(sizeMB int) error {
func enableSwap() error {
swapFile := "/swapfile"
if _, err := os.Stat(swapFile); os.IsNotExist(err) {
if getSwapInfo().Enabled {
return nil
}
return fmt.Errorf("swap 文件不存在,请先创建")
}
@@ -180,7 +203,7 @@ func disableSwap() error {
cmd := exec.Command("swapoff", swapFile)
output, err := cmd.CombinedOutput()
if err != nil {
if strings.Contains(string(output), "No such") {
if strings.Contains(string(output), "No such") || strings.Contains(string(output), "Invalid argument") {
return nil
}
return fmt.Errorf("禁用 swap 失败: %v, %s", err, string(output))
+68 -13
View File
@@ -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
+43 -6
View File
@@ -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) {
+179 -2
View File
@@ -21,8 +21,9 @@ import (
var manager = lxc.NewManager()
const (
clicdBackupDir = "/root/clicd-backups"
clicdNewBinaryPath = "/usr/local/bin/clicd.new"
clicdBackupDir = "/root/clicd-backups"
clicdNewBinaryPath = "/usr/local/bin/clicd.new"
libvirtDefaultNetworkMarker = "/var/lib/clicd/kvm/default-network.created"
)
// Run starts the CLI interface.
@@ -754,6 +755,7 @@ func cliUninstall(reader *bufio.Reader) {
destroyAllLXCContainers()
destroyAllKVMDomains()
removeCLICDLibvirtDefaultNetwork()
cleanupCLICDNetworking()
removeCLICDHostHooks()
removeCLICDQuotaRecords()
@@ -840,8 +842,60 @@ func removeKVMDomain(name string) {
runQuiet("virsh", "undefine", name)
}
func removeCLICDLibvirtDefaultNetwork() {
if !commandExists("virsh") || !fileExists(libvirtDefaultNetworkMarker) {
return
}
if libvirtDefaultUsedByNonCLICDDomain() {
fmt.Println("检测到非 CLICD 虚拟机仍在使用 libvirt default 网络,已保留 default/virbr0。")
return
}
fmt.Println("Removing CLICD-created libvirt default network...")
runQuiet("virsh", "net-destroy", "default")
runQuiet("virsh", "net-undefine", "default")
removePath(libvirtDefaultNetworkMarker)
}
func libvirtDefaultUsedByNonCLICDDomain() bool {
if !commandExists("virsh") {
return false
}
out, err := exec.Command("virsh", "list", "--all", "--name").Output()
if err != nil {
return false
}
for _, line := range strings.Split(string(out), "\n") {
name := strings.TrimSpace(line)
if name == "" || isCLICDKVMDomain(name) {
continue
}
if usesLibvirtDefaultNetwork(name) {
return true
}
}
return false
}
func usesLibvirtDefaultNetwork(domain string) bool {
out, err := exec.Command("virsh", "domiflist", domain).Output()
if err != nil {
return false
}
for _, line := range strings.Split(string(out), "\n") {
fields := strings.Fields(line)
for _, field := range fields {
if field == "default" || field == "virbr0" {
return true
}
}
}
return false
}
func cleanupCLICDNetworking() {
removeCLICDNATRules()
cleanupCLICDIPv6Runtime()
cleanupCLICDIPv6BridgeRoutes()
for _, bridge := range []string{"lxcbr0", "virbr0"} {
deleteFilterRule("FORWARD", "-i", bridge, "-j", "ACCEPT")
deleteFilterRule("FORWARD", "-o", bridge, "-j", "ACCEPT")
@@ -850,6 +904,123 @@ func cleanupCLICDNetworking() {
}
}
func cleanupCLICDIPv6Runtime() {
if config.AppConfig == nil {
return
}
for _, c := range config.AppConfig.Containers {
cleanupCLICDContainerIPv6(c)
}
}
func cleanupCLICDContainerIPv6(c config.Container) {
bridge := "lxcbr0"
if c.IsKVM() {
bridge = "virbr0"
}
mac := strings.ToLower(strings.TrimSpace(c.MACAddress))
if mac != "" && bridge == "virbr0" {
deleteIP6FilterRule("FORWARD", "-i", bridge, "-m", "mac", "--mac-source", mac, "-j", "DROP")
}
if strings.TrimSpace(c.IPv6) == "" {
return
}
addr := strings.TrimSpace(c.IPv6)
if slash := strings.Index(addr, "/"); slash >= 0 {
addr = addr[:slash]
}
source := strings.TrimSpace(c.IPv6)
if !strings.Contains(source, "/") {
source += "/128"
}
deleteIP6NATSource(source)
deleteIP6FilterRule("FORWARD", "-i", bridge, "-s", source, "-j", "ACCEPT")
deleteIP6FilterRule("FORWARD", "-o", bridge, "-d", source, "-j", "ACCEPT")
if mac != "" && bridge == "virbr0" {
deleteIP6FilterRule("FORWARD", "-i", bridge, "-m", "mac", "--mac-source", mac, "-s", source, "-j", "ACCEPT")
deleteIP6FilterRule("FORWARD", "-i", bridge, "-m", "mac", "--mac-source", mac, "-j", "DROP")
}
runQuiet("ip", "-6", "route", "del", source, "dev", bridge)
if strings.TrimSpace(c.IPv6Interface) != "" {
runQuiet("ip", "-6", "neigh", "del", "proxy", addr, "dev", c.IPv6Interface)
}
}
func cleanupCLICDIPv6BridgeRoutes() {
if !commandExists("ip") {
return
}
for _, bridge := range []string{"lxcbr0", "virbr0"} {
out, err := exec.Command("ip", "-6", "route", "show", "dev", bridge).Output()
if err == nil {
for _, line := range strings.Split(string(out), "\n") {
fields := strings.Fields(line)
if len(fields) == 0 || !strings.HasSuffix(fields[0], "/128") {
continue
}
source := fields[0]
addr := strings.TrimSuffix(source, "/128")
deleteIP6NATSource(source)
deleteIP6FilterRule("FORWARD", "-i", bridge, "-s", source, "-j", "ACCEPT")
deleteIP6FilterRule("FORWARD", "-o", bridge, "-d", source, "-j", "ACCEPT")
removeProxyNDPForAddress(addr)
runQuiet("ip", "-6", "route", "del", source, "dev", bridge)
}
}
runQuiet("ip", "-6", "addr", "del", "fe80::1/64", "dev", bridge)
}
}
func removeProxyNDPForAddress(addr string) {
out, err := exec.Command("ip", "-6", "neigh", "show", "proxy").Output()
if err != nil {
return
}
for _, line := range strings.Split(string(out), "\n") {
fields := strings.Fields(line)
if len(fields) == 0 || fields[0] != addr {
continue
}
for i := 0; i+1 < len(fields); i++ {
if fields[i] == "dev" {
runQuiet("ip", "-6", "neigh", "del", "proxy", addr, "dev", fields[i+1])
}
}
}
}
func deleteIP6NATSource(source string) {
if !commandExists("ip6tables") || strings.TrimSpace(source) == "" {
return
}
for {
out, err := exec.Command("ip6tables", "-t", "nat", "-S", "POSTROUTING").Output()
if err != nil {
return
}
deleted := false
for _, line := range strings.Split(string(out), "\n") {
if !strings.Contains(line, "-s "+source) || !strings.Contains(line, " -j MASQUERADE") {
continue
}
fields := strings.Fields(line)
if len(fields) == 0 || fields[0] != "-A" {
continue
}
fields[0] = "-D"
args := append([]string{"-t", "nat"}, fields...)
deleted = runCommandOK("ip6tables", args...)
break
}
if !deleted {
return
}
}
}
func removeCLICDNATRules() {
if commandExists("iptables") {
for {
@@ -879,6 +1050,12 @@ func deleteFilterRule(args ...string) {
}
}
func deleteIP6FilterRule(args ...string) {
fullArgs := append([]string{"-D"}, args...)
for runCommandOK("ip6tables", fullArgs...) {
}
}
func deleteIP6TablesBridgeRules(bridge string) {
if !commandExists("ip6tables") {
return
+141 -30
View File
@@ -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,46 @@ type Snapshot struct {
SizeBytes int64 `json:"size_bytes"`
}
const (
SSLModeDisabled = "disabled"
SSLModeLetsEncrypt = "letsencrypt"
SSLModeSelfSigned = "self_signed"
SSLModeUploaded = "uploaded"
)
type SSLConfig struct {
Enabled bool `json:"enabled"`
Mode string `json:"mode"`
Target string `json:"target"`
Email string `json:"email,omitempty"`
CertPath string `json:"cert_path,omitempty"`
KeyPath string `json:"key_path,omitempty"`
LastIssuedAt string `json:"last_issued_at,omitempty"`
LastError string `json:"last_error,omitempty"`
}
// ClicdConfig is the main configuration structure
type ClicdConfig struct {
AdminUser string `json:"admin_user"`
AdminPassHash string `json:"admin_pass_hash"`
JWTSecret string `json:"jwt_secret"`
Port int `json:"port"`
DataDir string `json:"data_dir"`
Containers []Container `json:"containers"`
NextContainerID int `json:"next_container_id"`
NextVNCPort int `json:"next_vnc_port"`
NextSSHPort int `json:"next_ssh_port"`
SetupComplete bool `json:"setup_complete"`
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"`
SSL SSLConfig `json:"ssl"`
SSLCertificates map[string]SSLConfig `json:"ssl_certificates"`
}
var configPath string
@@ -295,8 +322,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 +341,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 +393,127 @@ 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 normalizeSSLDefaults() {
changed = true
}
return changed
}
func normalizeSSLDefaults() bool {
changed := false
previousMode := AppConfig.SSL.Mode
AppConfig.SSL.Mode = NormalizeSSLMode(AppConfig.SSL.Mode)
if AppConfig.SSL.Mode != previousMode {
changed = true
}
if AppConfig.SSL.Mode == SSLModeDisabled {
if AppConfig.SSL.Enabled {
changed = true
}
AppConfig.SSL.Enabled = false
}
if AppConfig.SSLCertificates == nil {
AppConfig.SSLCertificates = map[string]SSLConfig{}
changed = true
}
for mode, cert := range AppConfig.SSLCertificates {
cert.Mode = NormalizeSSLMode(cert.Mode)
if cert.Mode == SSLModeDisabled {
delete(AppConfig.SSLCertificates, mode)
changed = true
continue
}
if AppConfig.SSLCertificates[cert.Mode] != cert {
changed = true
}
AppConfig.SSLCertificates[cert.Mode] = cert
if mode != cert.Mode {
delete(AppConfig.SSLCertificates, mode)
changed = true
}
}
if AppConfig.SSL.Mode != SSLModeDisabled && AppConfig.SSL.CertPath != "" && AppConfig.SSL.KeyPath != "" {
cert := AppConfig.SSL
cert.Enabled = false
if AppConfig.SSLCertificates[cert.Mode] != cert {
changed = true
}
AppConfig.SSLCertificates[cert.Mode] = cert
}
return changed
}
func NormalizeSSLMode(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case SSLModeLetsEncrypt:
return SSLModeLetsEncrypt
case SSLModeSelfSigned:
return SSLModeSelfSigned
case SSLModeUploaded:
return SSLModeUploaded
default:
return SSLModeDisabled
}
}
+107 -10
View File
@@ -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, &notNull, &defaultValue, &pk); err != nil {
return err
}
if columnName == name {
return nil
}
}
if err := rows.Err(); err != nil {
return err
}
_, err = db.Exec("ALTER TABLE " + table + " ADD COLUMN " + name + " " + def)
return err
}
func loadConfigFromDB() (*ClicdConfig, bool, error) {
meta := map[string]string{}
rows, err := db.Query("SELECT key, value FROM app_meta")
@@ -299,6 +374,12 @@ func loadConfigFromDB() (*ClicdConfig, bool, error) {
SetupComplete: atob(meta["setup_complete"]),
SecurityAutoShutdown: atob(meta["security_auto_shutdown"]),
}
if raw := strings.TrimSpace(meta["ssl"]); raw != "" {
_ = json.Unmarshal([]byte(raw), &cfg.SSL)
}
if raw := strings.TrimSpace(meta["ssl_certificates"]); raw != "" {
_ = json.Unmarshal([]byte(raw), &cfg.SSLCertificates)
}
if cfg.Containers, err = loadContainers(); err != nil {
return nil, false, err
@@ -391,6 +472,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 +485,8 @@ func saveMeta(tx *sql.Tx) error {
"next_ssh_port": strconv.Itoa(AppConfig.NextSSHPort),
"setup_complete": btoa(AppConfig.SetupComplete),
"security_auto_shutdown": btoa(AppConfig.SecurityAutoShutdown),
"ssl": string(sslJSON),
"ssl_certificates": string(sslCertificatesJSON),
"schema_version": "1",
"updated_at": time.Now().Format("2006-01-02 15:04:05"),
}
@@ -468,8 +553,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 +585,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 +756,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 +764,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 +803,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 +819,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 +829,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)
+4
View File
@@ -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
View File
@@ -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)
}
+20 -13
View File
@@ -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)
}
})
+78 -1
View File
@@ -1,11 +1,13 @@
package server
import (
"crypto/tls"
"fmt"
"log"
"net"
"net/http"
"net/url"
"os"
"strings"
"clicd/internal/api"
@@ -23,7 +25,7 @@ func corsMiddleware(next http.HandlerFunc) http.HandlerFunc {
w.Header().Set("Vary", "Origin")
w.Header().Set("Access-Control-Allow-Credentials", "true")
}
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-API-Key")
if r.Method == http.MethodOptions {
@@ -75,7 +77,9 @@ func setupRoutes(mux *http.ServeMux) {
mux.HandleFunc("/api/change-password", corsMiddleware(api.AdminMiddleware(api.HandleAdminPasswordChange)))
mux.HandleFunc("/api/change-username", corsMiddleware(api.AdminMiddleware(api.HandleAdminUsernameChange)))
mux.HandleFunc("/api/login-logs", corsMiddleware(api.AdminMiddleware(api.HandleLoginLogs)))
mux.HandleFunc("/api/ssl", corsMiddleware(api.AdminMiddleware(api.HandleSSLSettings)))
mux.HandleFunc("/api/containers", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleContainers))))
mux.HandleFunc("/api/containers/list", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleContainerListAlias))))
mux.HandleFunc("/api/containers/", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleSingleContainer))))
mux.HandleFunc("/api/templates", corsMiddleware(api.AuthMiddleware(api.HandleTemplates)))
mux.HandleFunc("/api/images", corsMiddleware(api.AdminMiddleware(api.HandleImages)))
@@ -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,50 @@ 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/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 +212,33 @@ func Run() error {
Handler: mux,
}
if sslEnabled() {
server.TLSConfig = &tls.Config{
MinVersion: tls.VersionTLS12,
GetCertificate: func(*tls.ClientHelloInfo) (*tls.Certificate, error) {
cert, err := tls.LoadX509KeyPair(config.AppConfig.SSL.CertPath, config.AppConfig.SSL.KeyPath)
return &cert, err
},
}
log.Printf("CLICD Web Server SSL enabled on https://0.0.0.0:%d", config.AppConfig.Port)
return server.ListenAndServeTLS("", "")
}
return server.ListenAndServe()
}
func sslEnabled() bool {
ssl := config.AppConfig.SSL
if !ssl.Enabled || ssl.CertPath == "" || ssl.KeyPath == "" {
return false
}
if _, err := os.Stat(ssl.CertPath); err != nil {
log.Printf("SSL certificate is not readable, falling back to HTTP: %v", err)
return false
}
if _, err := os.Stat(ssl.KeyPath); err != nil {
log.Printf("SSL private key is not readable, falling back to HTTP: %v", err)
return false
}
return true
}
+1
View File
@@ -0,0 +1 @@

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

After

Width:  |  Height:  |  Size: 1.3 KiB

+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "clicd-frontend",
"private": true,
"version": "1.1.3",
"version": "1.1.7",
"type": "module",
"scripts": {
"dev": "vite",
+2
View File
@@ -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>
+14
View File
@@ -4,6 +4,7 @@ import {
ChevronLeft,
ChevronRight,
Code2,
Cpu,
Camera,
LayoutDashboard,
LogOut,
@@ -71,6 +72,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')
@@ -222,6 +224,18 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
{!collapsed && <span>API </span>}
</button>
<button
onClick={() => navigate('/host-report')}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
isHostReportPage
? 'bg-black text-white dark:bg-white dark:text-black'
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'
}`}
>
<Cpu className="w-4 h-4" />
{!collapsed && <span>宿</span>}
</button>
<button
onClick={() => navigate('/settings')}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
+46 -2
View File
@@ -21,6 +21,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
@@ -47,7 +87,10 @@ 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
@@ -76,7 +119,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}`)
}
}
File diff suppressed because it is too large Load Diff
+168 -103
View File
@@ -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
+328
View File
@@ -0,0 +1,328 @@
import { ReactNode, useCallback, useEffect, useState } from 'react'
import {
Activity,
CheckCircle2,
Cpu,
HardDrive,
MemoryStick,
RefreshCw,
XCircle,
} from 'lucide-react'
import { getHostReport, HostProbeReport } from '../services/api'
export default function HostReport() {
const [report, setReport] = useState<HostProbeReport | null>(null)
const [loading, setLoading] = useState(true)
const fetchReport = useCallback(async () => {
setLoading(true)
try {
const res = await getHostReport()
setReport(res.data.data || null)
} catch (err) {
console.error(err)
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchReport()
}, [fetchReport])
return (
<div className="space-y-6">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h1 className="text-2xl font-bold text-black">宿</h1>
<p className="mt-1 text-sm text-gray-500"></p>
</div>
<button onClick={fetchReport} disabled={loading} className="inline-flex items-center gap-1.5 rounded-md border border-gray-200 px-3 py-2 text-sm text-gray-600 hover:bg-gray-50 disabled:opacity-50">
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
</button>
</div>
{loading && !report ? (
<div className="rounded-lg border border-gray-200 bg-white py-14 text-center text-sm text-gray-400">宿...</div>
) : !report ? (
<div className="rounded-lg border border-gray-200 bg-white py-14 text-center text-sm text-gray-400">宿</div>
) : (
<div className="space-y-5">
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<ProbeMetric icon={<Cpu className="h-4 w-4" />} label="CPU" value={report.cpu.model || 'Unknown'} sub={`${report.cpu.cores} 核 / ${report.cpu.threads} 线程`} />
<ProbeMetric icon={<MemoryStick className="h-4 w-4" />} label="RAM" value={formatMB(report.memory.total_mb)} sub={`${formatMB(report.memory.used_mb)} 已用`} />
<ProbeMetric icon={<HardDrive className="h-4 w-4" />} label="DISK" value={`${report.disks.length} 块硬盘`} sub={report.disks.map(d => d.type).filter(Boolean).join(' / ') || 'Unknown'} />
<ProbeMetric icon={<Activity className="h-4 w-4" />} label="运行状态" value={report.system.uptime_text} sub={`${report.system.process_count} 个进程`} />
</div>
<ProbeSection title="系统概览">
<ProbeRows rows={[
['主机名', report.hostname],
['操作系统', report.os],
['内核', report.kernel],
['生成时间', report.generated_at],
['CPU 架构', report.cpu.architecture],
['CPU 虚拟化指令', report.cpu.virtualization ? `支持 (${report.cpu.virtualization_key})` : '未检测到'],
['CPU 核显', report.cpu.has_integrated_gpu ? '检测到' : '未检测到'],
['显卡', report.gpus.length ? `${report.gpus.length}` : '未检测到'],
['运行能力', runtimeModeLabel(report.runtime.support_mode)],
['KVM 嵌套虚拟化', `${report.runtime.nested_virtualization ? '支持' : '未检测到'} (${report.runtime.nested_detail || '-'})`],
]} />
</ProbeSection>
<ProbeSection title="公网与路由">
<ProbeRows rows={[
['公网 IPv4', report.public_ipv4.length ? report.public_ipv4.join('\n') : '未检测到'],
['IPv4 地址', report.ipv4_addresses?.length ? report.ipv4_addresses.map(formatIPv4Address).join('\n') : '未检测到'],
['IPv4 段', report.ipv4_prefixes?.length ? report.ipv4_prefixes.map(formatIPv4Prefix).join('\n') : '未检测到'],
['IPv6 地址', report.ipv6_addresses.length ? report.ipv6_addresses.map(ip => `${ip.address}/${ip.prefix_len} (${ip.interface})`).join('\n') : '未检测到'],
['IPv6 段', report.ipv6_prefixes?.length ? report.ipv6_prefixes.map(formatIPv6Prefix).join('\n') : '未检测到'],
['网关', report.gateways.length ? report.gateways.map(g => `${g.family}: ${g.gateway || '-'} dev ${g.interface || '-'}`).join('\n') : '未检测到'],
]} />
</ProbeSection>
<ProbeTable
title="内存条"
empty="未检测到内存条明细,可能缺少 dmidecode 或权限受限"
headers={['插槽', '容量', '类型', '频率', '厂商', '型号/序列号']}
rows={(report.memory.modules || []).map(m => [
m.locator || '-',
m.size || '-',
m.type || '-',
m.speed || '-',
m.manufacturer || '-',
[m.part_number, m.serial_number].filter(Boolean).join(' / ') || '-',
])}
/>
<ProbeTable
title="硬盘与健康"
empty="未检测到硬盘"
headers={['设备', '型号', '容量', '类型', '挂载点', '健康', '寿命', '通电', '读取', '写入', '命令数', '擦写']}
rows={report.disks.map(d => [
`${d.path || d.name}\n${d.serial || ''}`,
d.model || '-',
formatBytes(d.size_bytes),
d.type || (d.rotational ? 'HDD' : 'SSD'),
d.mountpoints?.length ? d.mountpoints.join('\n') : '-',
`${diskHealthLabel(d.health)}\n${d.health_detail || ''}`,
formatLifeUsed(d.smart?.life_used_percent),
d.smart?.power_on_hours ? `${d.smart.power_on_hours} 小时\n${formatPowerOnDays(d.smart.power_on_hours)}` : '-',
formatBytes(d.smart?.read_data_bytes || 0),
formatBytes(d.smart?.written_data_bytes || 0),
formatCommands(d.smart?.read_commands, d.smart?.write_commands),
formatWear(d.smart?.wear_leveling_count, d.smart?.erase_count, d.smart?.power_cycle_count),
])}
/>
<ProbeTable
title="网卡"
empty="未检测到网卡"
headers={['网卡', '状态', '驱动/速率', 'MAC', 'IPv4', 'IPv6']}
rows={report.network_interfaces.map(n => [
`${n.name}\n${n.model || ''}`,
n.state || '-',
`${n.driver || '-'}\n${n.speed_mbps > 0 ? `${n.speed_mbps} Mbps` : '-'}`,
n.mac || '-',
n.ipv4?.length ? n.ipv4.map(ip => `${ip.address}/${ip.prefix_len}`).join('\n') : '-',
n.ipv6?.length ? n.ipv6.map(ip => `${ip.address}/${ip.prefix_len} ${ip.scope}`).join('\n') : '-',
])}
/>
<ProbeTable
title="显卡"
empty="未检测到显卡"
headers={['名称', '厂商', '类型', '驱动']}
rows={report.gpus.map(g => [g.name, g.vendor || '-', gpuTypeLabel(g.type), g.driver || '-'])}
/>
<ProbeSection title="环境支持">
<div className="grid gap-2 md:grid-cols-2">
{report.environment.map(item => (
<div key={item.key} className="flex items-start gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2">
{item.ok ? <CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0 text-green-600" /> : <XCircle className={`mt-0.5 h-4 w-4 shrink-0 ${item.required ? 'text-red-600' : 'text-amber-600'}`} />}
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2 text-xs font-medium text-gray-800">
<span>{item.label}</span>
<span className={`rounded px-1.5 py-0.5 text-[10px] ${item.required ? 'bg-gray-100 text-gray-600' : 'bg-blue-50 text-blue-700'}`}>
{item.required ? '必要' : '可选'}
</span>
</div>
<div className="mt-1 break-all font-mono text-[11px] text-gray-500">{item.detail || '-'}</div>
</div>
</div>
))}
</div>
</ProbeSection>
</div>
)}
</div>
)
}
function ProbeMetric({ icon, label, value, sub }: { icon: ReactNode; label: string; value: string; sub: string }) {
return (
<div className="rounded-lg border border-gray-200 bg-white px-3 py-3">
<div className="mb-2 flex items-center gap-2 text-xs font-medium text-gray-500">
{icon}
{label}
</div>
<div className="line-clamp-2 break-words text-sm font-semibold text-gray-900" title={value}>{value}</div>
<div className="mt-1 truncate text-xs text-gray-500" title={sub}>{sub}</div>
</div>
)
}
function ProbeSection({ title, children }: { title: string; children: ReactNode }) {
return (
<section>
<h2 className="mb-2 text-sm font-semibold text-black">{title}</h2>
{children}
</section>
)
}
function ProbeRows({ rows }: { rows: Array<[string, string]> }) {
return (
<div className="overflow-hidden rounded-lg border border-gray-200 bg-white">
{rows.map(([label, value]) => (
<div key={label} className="grid gap-2 border-b border-gray-100 px-3 py-2 text-xs last:border-b-0 md:grid-cols-[160px_1fr]">
<div className="font-medium text-gray-500">{label}</div>
<div className="whitespace-pre-wrap break-words font-mono text-gray-800">{value || '-'}</div>
</div>
))}
</div>
)
}
function ProbeTable({ title, headers, rows, empty }: { title: string; headers: string[]; rows: string[][]; empty: string }) {
return (
<section>
<h2 className="mb-2 text-sm font-semibold text-black">{title}</h2>
{rows.length === 0 ? (
<div className="rounded-lg border border-gray-200 bg-white px-3 py-3 text-xs text-gray-400">{empty}</div>
) : (
<div className="overflow-x-auto rounded-lg border border-gray-200 bg-white">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-gray-100 bg-gray-50 text-left text-gray-500">
{headers.map(header => <th key={header} className="px-3 py-2 font-medium">{header}</th>)}
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{rows.map((row, rowIndex) => (
<tr key={rowIndex} className="align-top">
{row.map((cell, cellIndex) => (
<td key={cellIndex} className="max-w-[280px] whitespace-pre-wrap break-words px-3 py-2 text-gray-700">
{cell || '-'}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
)
}
function formatIPv4Address(ip: HostProbeReport['ipv4_addresses'][number]) {
return `${ip.address}/${ip.prefix_len} (${ip.interface})`
}
function formatIPv4Prefix(prefix: HostProbeReport['ipv4_prefixes'][number]) {
const parts = [
prefix.prefix || '-',
prefix.subnet_mask ? `mask ${prefix.subnet_mask}` : '',
prefix.gateway ? `via ${prefix.gateway}` : '',
prefix.interface ? `dev ${prefix.interface}` : '',
prefix.source ? `[${prefix.source}]` : '',
].filter(Boolean)
return parts.join(' ')
}
function formatIPv6Prefix(prefix: HostProbeReport['ipv6_prefixes'][number]) {
const value = prefix.prefix || prefix.address || '-'
const cidr = value.includes('/') || !prefix.prefix_len ? value : `${value}/${prefix.prefix_len}`
return `${cidr} via ${prefix.gateway || '-'}`
}
function formatMB(value: number) {
if (!value) return '-'
if (value >= 1024) return `${(value / 1024).toFixed(1)} GB`
return `${value} MB`
}
function formatBytes(value: number) {
if (!value) return '-'
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']
let next = value
let index = 0
while (next >= 1024 && index < units.length - 1) {
next /= 1024
index++
}
return `${next.toFixed(index === 0 ? 0 : 1)} ${units[index]}`
}
function formatLifeUsed(value?: number) {
if (value === undefined || value === null) return '-'
return `${value}% 已用\n${Math.max(0, 100 - value)}% 剩余`
}
function formatPowerOnDays(hours: number) {
const days = Math.floor(hours / 24)
const rest = hours % 24
return days > 0 ? `${days}${rest} 小时` : `${hours} 小时`
}
function formatCommands(read?: number, write?: number) {
if (!read && !write) return '-'
return `${formatCount(read || 0)}\n写 ${formatCount(write || 0)}`
}
function formatCount(value: number) {
if (!value) return '-'
if (value >= 1_000_000_000) return `${(value / 1_000_000_000).toFixed(1)}B`
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`
if (value >= 1_000) return `${(value / 1_000).toFixed(1)}K`
return `${value}`
}
function formatWear(wear?: string, erase?: string, powerCycles?: number) {
const rows: string[] = []
if (wear) rows.push(`磨损 ${wear}`)
if (erase) rows.push(`擦写 ${erase}`)
if (powerCycles) rows.push(`启停 ${powerCycles}`)
return rows.length ? rows.join('\n') : '-'
}
function runtimeModeLabel(value: string) {
switch (value) {
case 'kvm_lxc':
return '支持 KVM + LXC'
case 'lxc_only':
return '仅支持 LXC'
default:
return '未满足运行环境'
}
}
function diskHealthLabel(value: string) {
switch (value) {
case 'ok':
return '健康'
case 'failed':
return '异常'
default:
return '未知'
}
}
function gpuTypeLabel(value: string) {
if (value === 'integrated') return '核显'
if (value === 'discrete') return '独显'
return value || '-'
}
+1 -1
View File
@@ -106,7 +106,7 @@ export default function Login() {
</form>
</div>
<p className="text-center text-xs text-gray-400 mt-6">CLICD v1.1.3</p>
<p className="text-center text-xs text-gray-400 mt-6">CLICD v1.1.7</p>
</div>
</div>
)
+332 -98
View File
@@ -1,10 +1,13 @@
import { useState, useEffect, useCallback } from 'react'
import { UserCog, Key, LogIn, Monitor, Clock, Globe } from 'lucide-react'
import { Dispatch, SetStateAction, useCallback, useEffect, useState } from 'react'
import { Clock, Globe, Lock, LogIn, Monitor, RefreshCw, ShieldCheck, Upload, UserCog } from 'lucide-react'
import {
changePassword,
changeUsername,
getLoginLogs,
getSSLSettings,
LoginLog,
SSLSettings,
updateSSLSettings,
} from '../services/api'
import { useDialog } from '../components/Dialog'
import { useAuth } from '../contexts/AuthContext'
@@ -20,7 +23,16 @@ export default function Settings() {
const [oldPwd, setOldPwd] = useState('')
const [newPwd, setNewPwd] = useState('')
const [newUsername, setNewUsername] = useState('')
const [pwdForUser, setPwdForUser] = useState('')
const [ssl, setSSL] = useState<SSLSettings | null>(null)
const [sslEnabled, setSSLEnabled] = useState(false)
const [sslMode, setSSLMode] = useState<SSLSettings['mode']>('disabled')
const [sslTarget, setSSLTarget] = useState('')
const [sslEmail, setSSLEmail] = useState('')
const [certPEM, setCertPEM] = useState('')
const [keyPEM, setKeyPEM] = useState('')
const [applyNow, setApplyNow] = useState(true)
const [savingSSL, setSavingSSL] = useState(false)
const fetchLogs = useCallback(async () => {
try {
@@ -33,30 +45,95 @@ export default function Settings() {
}
}, [])
useEffect(() => { fetchLogs(); const t = setInterval(fetchLogs, 15000); return () => clearInterval(t) }, [fetchLogs])
const fetchSSL = useCallback(async () => {
try {
const res = await getSSLSettings()
const data = res.data.data
if (!data) return
setSSL(data)
setSSLEnabled(data.enabled)
setSSLMode(data.mode || 'disabled')
setSSLTarget(data.target || data.detected_host || '')
setSSLEmail(data.email || '')
} catch (err) {
console.error(err)
}
}, [])
useEffect(() => {
fetchLogs()
fetchSSL()
const timer = setInterval(fetchLogs, 15000)
return () => clearInterval(timer)
}, [fetchLogs, fetchSSL])
const handleSSLModeChange = (mode: SSLSettings['mode']) => {
setSSLMode(mode)
const saved = ssl?.mode_certificates?.[mode]
setSSLTarget(saved?.target || ssl?.detected_host || sslTarget)
setSSLEmail(saved?.email || '')
}
const handleSaveSSL = async () => {
setSavingSSL(true)
try {
const enabled = sslEnabled && sslMode !== 'disabled'
const res = await updateSSLSettings({
enabled,
mode: enabled ? sslMode : 'disabled',
target: sslTarget,
email: sslEmail,
cert_pem: certPEM,
key_pem: keyPEM,
apply_now: applyNow,
})
if (res.data.data) {
setSSL(res.data.data)
setCertPEM('')
setKeyPEM('')
}
dialog.alert('完成', applyNow ? 'SSL 设置已保存,服务正在重启。稍后请用新的协议重新打开面板。' : 'SSL 设置已保存,重启 clicd 服务后生效。')
} catch (err: unknown) {
const e = err as { response?: { data?: { message?: string } } }
dialog.alert('失败', e.response?.data?.message || 'SSL 设置保存失败')
} finally {
setSavingSSL(false)
}
}
const handleSaveAccount = async () => {
if (!oldPwd) { dialog.alert('提示', '请输入当前密码以确认修改'); return }
if (!newPwd && !newUsername) { dialog.alert('提示', '至少填写新密码或新用户名中的一项'); return }
if (newPwd && newPwd.length < 6) { dialog.alert('提示', '新密码至少 6 位'); return }
if (newUsername && newUsername.length < 3) { dialog.alert('提示', '用户名至少 3 位'); return }
if (!oldPwd) {
dialog.alert('提示', '请输入当前密码以确认修改')
return
}
if (!newPwd && !newUsername) {
dialog.alert('提示', '至少填写新密码或新用户名中的一项')
return
}
if (newPwd && newPwd.length < 6) {
dialog.alert('提示', '新密码至少 6 位')
return
}
if (newUsername && newUsername.length < 3) {
dialog.alert('提示', '用户名至少 3 位')
return
}
let results: string[] = []
const results: string[] = []
try {
// 先改用户名(用旧密码验证),再改密码,否则改完密码后旧密码就失效了
if (newUsername) {
const res = await changeUsername(newUsername, oldPwd)
if (res.data.success) results.push('用户名已修改')
else results.push('用户名修改失败')
results.push(res.data.success ? '用户名已修改' : '用户名修改失败')
}
if (newPwd) {
const res = await changePassword(oldPwd, newPwd)
if (res.data.success) results.push('密码已修改')
else results.push('密码修改失败')
results.push(res.data.success ? '密码已修改' : '密码修改失败')
}
if (results.length > 0) {
dialog.alert('完成', results.join('') + '。下次登录生效')
setOldPwd(''); setNewPwd(''); setNewUsername('')
dialog.alert('完成', `${results.join('')}。下次登录生效`)
setOldPwd('')
setNewPwd('')
setNewUsername('')
}
} catch (err: unknown) {
const e = err as { response?: { data?: { message?: string } } }
@@ -67,111 +144,268 @@ export default function Settings() {
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-black"></div>
<div className="h-8 w-8 animate-spin rounded-full border-b-2 border-black"></div>
</div>
)
}
const totalPages = Math.ceil(logs.length / pageSize)
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-black"></h1>
<p className="text-sm text-gray-500 mt-1"></p>
<p className="mt-1 text-sm text-gray-500"></p>
</div>
{/* Account Settings */}
<div className="bg-white border border-gray-200 rounded-lg p-5">
<h2 className="text-sm font-semibold text-black mb-4 flex items-center gap-2">
<UserCog className="w-4 h-4" />
</h2>
<div className="space-y-4">
<div>
<label className="block text-xs text-gray-500 mb-1"></label>
<input type="text" value={username || ''} disabled className="w-full px-3 py-2 border border-gray-200 rounded-md text-sm text-gray-400 bg-gray-50" />
<div className="grid items-start gap-6 xl:grid-cols-[minmax(0,1.15fr)_minmax(360px,0.85fr)]">
<SSLCard
ssl={ssl}
sslEnabled={sslEnabled}
sslMode={sslMode}
sslTarget={sslTarget}
sslEmail={sslEmail}
certPEM={certPEM}
keyPEM={keyPEM}
applyNow={applyNow}
savingSSL={savingSSL}
onRefresh={fetchSSL}
onEnabledChange={setSSLEnabled}
onModeChange={handleSSLModeChange}
onTargetChange={setSSLTarget}
onEmailChange={setSSLEmail}
onCertChange={setCertPEM}
onKeyChange={setKeyPEM}
onApplyNowChange={setApplyNow}
onSave={handleSaveSSL}
/>
<div className="rounded-lg border border-gray-200 bg-white p-5">
<h2 className="mb-4 flex items-center gap-2 text-sm font-semibold text-black">
<UserCog className="h-4 w-4" />
</h2>
<div className="space-y-4">
<div>
<label className="mb-1 block text-xs text-gray-500"></label>
<input type="text" value={username || ''} disabled className="w-full rounded-md border border-gray-200 bg-gray-50 px-3 py-2 text-sm text-gray-400" />
</div>
<div>
<label className="mb-1 block text-xs text-gray-500"></label>
<input type="text" value={newUsername} onChange={(e) => setNewUsername(e.target.value)} className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black" placeholder="至少 3 位" />
</div>
<div className="border-t border-gray-100 pt-3">
<label className="mb-1 block text-xs text-gray-500"></label>
<input type="password" value={newPwd} onChange={(e) => setNewPwd(e.target.value)} className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black" placeholder="至少 6 位" />
</div>
<div>
<label className="mb-1 block text-xs text-gray-500"></label>
<input type="password" value={oldPwd} onChange={(e) => setOldPwd(e.target.value)} className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black" placeholder="输入当前密码以确认修改" />
</div>
<button onClick={handleSaveAccount} className="w-full rounded-md bg-black px-4 py-2 text-sm text-white hover:bg-gray-800"></button>
</div>
<div>
<label className="block text-xs text-gray-500 mb-1"></label>
<input type="text" value={newUsername} onChange={(e) => setNewUsername(e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm text-black bg-white" placeholder="至少 3 位" />
</div>
<div className="border-t border-gray-100 pt-3">
<label className="block text-xs text-gray-500 mb-1"></label>
<input type="password" value={newPwd} onChange={(e) => setNewPwd(e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm text-black bg-white" placeholder="至少 6 位" />
</div>
<div>
<label className="block text-xs text-gray-500 mb-1"></label>
<input type="password" value={oldPwd} onChange={(e) => setOldPwd(e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm text-black bg-white" placeholder="输入当前密码以确认修改" />
</div>
<button onClick={handleSaveAccount} className="w-full px-4 py-2 bg-black text-white rounded-md text-sm hover:bg-gray-800"></button>
</div>
</div>
{/* Login Logs */}
<div className="bg-white border border-gray-200 rounded-lg p-5">
<h2 className="text-sm font-semibold text-black mb-4 flex items-center gap-2">
<LogIn className="w-4 h-4" />
<LoginLogCard logs={logs} logPage={logPage} pageSize={pageSize} totalPages={totalPages} setLogPage={setLogPage} />
</div>
)
}
interface SSLCardProps {
ssl: SSLSettings | null
sslEnabled: boolean
sslMode: SSLSettings['mode']
sslTarget: string
sslEmail: string
certPEM: string
keyPEM: string
applyNow: boolean
savingSSL: boolean
onRefresh: () => void
onEnabledChange: (enabled: boolean) => void
onModeChange: (mode: SSLSettings['mode']) => void
onTargetChange: (target: string) => void
onEmailChange: (email: string) => void
onCertChange: (cert: string) => void
onKeyChange: (key: string) => void
onApplyNowChange: (apply: boolean) => void
onSave: () => void
}
function SSLCard(props: SSLCardProps) {
const selectedSSL = props.ssl?.mode_certificates?.[props.sslMode]
const modeOptions: Array<{ value: SSLSettings['mode']; label: string }> = [
{ value: 'letsencrypt', label: 'Lets Encrypt' },
{ value: 'self_signed', label: '自签证书' },
{ value: 'uploaded', label: '上传证书' },
]
return (
<div className="rounded-lg border border-gray-200 bg-white p-5">
<div className="mb-4 flex items-center justify-between gap-3">
<h2 className="flex items-center gap-2 text-sm font-semibold text-black">
<ShieldCheck className="h-4 w-4" />SSL
</h2>
{logs.length === 0 ? (
<p className="text-sm text-gray-400"></p>
) : (
<>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="text-gray-400 border-b border-gray-100">
<th className="text-left py-2 font-medium w-40"><span className="inline-flex items-center gap-1"><Clock className="w-3 h-3" /></span></th>
<th className="text-left py-2 font-medium"></th>
<th className="text-left py-2 font-medium"><span className="inline-flex items-center gap-1"><Globe className="w-3 h-3" />IP</span></th>
<th className="text-left py-2 font-medium"><span className="inline-flex items-center gap-1"><Monitor className="w-3 h-3" /></span></th>
<th className="text-left py-2 font-medium"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-50">
{logs.slice((logPage - 1) * pageSize, logPage * pageSize).map((log, i) => (
<tr key={i}>
<td className="py-1.5 text-gray-500 font-mono whitespace-nowrap">{log.time}</td>
<td className="py-1.5 text-gray-700">{log.username}</td>
<td className="py-1.5 text-gray-500 font-mono">{log.ip}</td>
<td className="py-1.5 text-gray-500 max-w-[180px] truncate" title={log.user_agent}>{formatUA(log.user_agent)}</td>
<td className="py-1.5">
<span className={`px-1.5 py-0.5 rounded text-xs ${log.success ? 'bg-gray-100 text-gray-700' : 'bg-red-50 text-red-600'}`}>
{log.success ? '成功' : '失败'}
</span>
</td>
</tr>
))}
</tbody>
</table>
<button onClick={props.onRefresh} className="rounded-md border border-gray-200 p-1.5 text-gray-500 hover:bg-gray-50" title="刷新">
<RefreshCw className="h-4 w-4" />
</button>
</div>
<div className="space-y-4">
<label className="flex items-center gap-2 text-sm text-gray-700">
<input type="checkbox" checked={props.sslEnabled} onChange={(e) => props.onEnabledChange(e.target.checked)} className="h-4 w-4 rounded border-gray-300" />
HTTPS / WSS
</label>
<div className="grid gap-2 sm:grid-cols-3">
{modeOptions.map((option) => (
<button
key={option.value}
onClick={() => props.onModeChange(option.value)}
className={`rounded-md border px-3 py-2 text-sm ${props.sslMode === option.value ? 'border-black bg-black text-white' : 'border-gray-200 text-gray-700 hover:bg-gray-50'}`}
>
{option.label}
</button>
))}
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div>
<label className="mb-1 block text-xs text-gray-500">IP / </label>
<input
type="text"
value={props.sslTarget}
onChange={(e) => props.onTargetChange(e.target.value)}
className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black"
placeholder={props.ssl?.detected_host || '服务器公网 IP 或域名'}
/>
</div>
{props.sslMode === 'letsencrypt' && (
<div>
<label className="mb-1 block text-xs text-gray-500"></label>
<input type="email" value={props.sslEmail} onChange={(e) => props.onEmailChange(e.target.value)} className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black" placeholder="admin@example.com" />
</div>
{logs.length > pageSize && (
<div className="flex items-center justify-between mt-3 pt-3 border-t border-gray-100">
<span className="text-xs text-gray-400"> {logs.length} {logPage}/{Math.ceil(logs.length / pageSize)} </span>
<div className="flex items-center gap-1">
<button onClick={() => setLogPage(1)} disabled={logPage === 1} className="px-2 py-1 text-xs border border-gray-200 rounded hover:bg-gray-50 disabled:opacity-30"></button>
<button onClick={() => setLogPage(p => Math.max(1, p - 1))} disabled={logPage === 1} className="px-2 py-1 text-xs border border-gray-200 rounded hover:bg-gray-50 disabled:opacity-30"></button>
{Array.from({length: Math.min(5, Math.ceil(logs.length / pageSize))}, (_, i) => {
const totalPages = Math.ceil(logs.length / pageSize)
let start = Math.max(1, logPage - 2)
if (start + 4 > totalPages) start = Math.max(1, totalPages - 4)
const page = start + i
if (page > totalPages) return null
return (
<button key={page} onClick={() => setLogPage(page)} className={`w-7 h-7 text-xs rounded ${page === logPage ? 'bg-black text-white' : 'border border-gray-200 hover:bg-gray-50'}`}>{page}</button>
)
})}
<button onClick={() => setLogPage(p => Math.min(Math.ceil(logs.length / pageSize), p + 1))} disabled={logPage >= Math.ceil(logs.length / pageSize)} className="px-2 py-1 text-xs border border-gray-200 rounded hover:bg-gray-50 disabled:opacity-30"></button>
<button onClick={() => setLogPage(Math.ceil(logs.length / pageSize))} disabled={logPage >= Math.ceil(logs.length / pageSize)} className="px-2 py-1 text-xs border border-gray-200 rounded hover:bg-gray-50 disabled:opacity-30"></button>
</div>
</div>
)}
</>
)}
</div>
{props.sslMode === 'letsencrypt' && (
<div className="rounded-md border border-amber-200 bg-amber-50 p-3 text-xs text-amber-800">
IP Certbot 5.4+ 80 Lets Encrypt 访IP certbot
</div>
)}
{props.sslMode === 'self_signed' && (
<div className="rounded-md border border-gray-100 bg-gray-50 p-3 text-xs text-gray-600">
VNC
</div>
)}
{props.sslMode === 'uploaded' && (
<div className="grid gap-3 lg:grid-cols-2">
<div>
<label className="mb-1 block text-xs text-gray-500"> PEM / fullchain.pem</label>
<textarea value={props.certPEM} onChange={(e) => props.onCertChange(e.target.value)} rows={7} className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 font-mono text-xs text-black" placeholder="-----BEGIN CERTIFICATE-----" />
</div>
<div>
<label className="mb-1 block text-xs text-gray-500"> PEM / privkey.pem</label>
<textarea value={props.keyPEM} onChange={(e) => props.onKeyChange(e.target.value)} rows={7} className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 font-mono text-xs text-black" placeholder="-----BEGIN PRIVATE KEY-----" />
</div>
</div>
)}
{selectedSSL?.certificate ? (
<div className="rounded-md border border-gray-100 bg-gray-50 p-3 text-xs text-gray-600">
<div className="flex items-center gap-2 text-gray-800">
<Lock className="h-3.5 w-3.5" />
{selectedSSL.certificate.valid ? '有效' : '已过期或未生效'}
</div>
<div className="mt-1 font-mono">{selectedSSL.certificate.not_after}</div>
<div className="mt-1 truncate font-mono" title={selectedSSL.cert_path}>{selectedSSL.cert_path || '-'}</div>
{selectedSSL.last_error && <div className="mt-1 text-red-600">{selectedSSL.last_error}</div>}
</div>
) : (
<div className="rounded-md border border-gray-100 bg-gray-50 p-3 text-xs text-gray-600">
{props.sslMode === 'uploaded' ? '上传来源还没有保存证书,请粘贴证书和私钥后保存。' : '当前来源还没有保存证书,保存 SSL 设置时会自动生成或申请。'}
</div>
)}
<label className="flex items-center gap-2 text-xs text-gray-500">
<input type="checkbox" checked={props.applyNow} onChange={(e) => props.onApplyNowChange(e.target.checked)} className="h-4 w-4 rounded border-gray-300" />
</label>
<button onClick={props.onSave} disabled={props.savingSSL} className="inline-flex w-full items-center justify-center gap-2 rounded-md bg-black px-4 py-2 text-sm text-white hover:bg-gray-800 disabled:opacity-50">
<Upload className="h-4 w-4" />
{props.savingSSL ? '保存中...' : '保存 SSL 设置'}
</button>
</div>
</div>
)
}
interface LoginLogCardProps {
logs: LoginLog[]
logPage: number
pageSize: number
totalPages: number
setLogPage: Dispatch<SetStateAction<number>>
}
function LoginLogCard({ logs, logPage, pageSize, totalPages, setLogPage }: LoginLogCardProps) {
return (
<div className="rounded-lg border border-gray-200 bg-white p-5">
<h2 className="mb-4 flex items-center gap-2 text-sm font-semibold text-black">
<LogIn className="h-4 w-4" />
</h2>
{logs.length === 0 ? (
<p className="text-sm text-gray-400"></p>
) : (
<>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-gray-100 text-gray-400">
<th className="w-40 py-2 text-left font-medium"><span className="inline-flex items-center gap-1"><Clock className="h-3 w-3" /></span></th>
<th className="py-2 text-left font-medium"></th>
<th className="py-2 text-left font-medium"><span className="inline-flex items-center gap-1"><Globe className="h-3 w-3" />IP</span></th>
<th className="py-2 text-left font-medium"><span className="inline-flex items-center gap-1"><Monitor className="h-3 w-3" /></span></th>
<th className="py-2 text-left font-medium"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-50">
{logs.slice((logPage - 1) * pageSize, logPage * pageSize).map((log, index) => (
<tr key={`${log.time}-${index}`}>
<td className="whitespace-nowrap py-1.5 font-mono text-gray-500">{log.time}</td>
<td className="py-1.5 text-gray-700">{log.username}</td>
<td className="py-1.5 font-mono text-gray-500">{log.ip}</td>
<td className="max-w-[180px] truncate py-1.5 text-gray-500" title={log.user_agent}>{formatUA(log.user_agent)}</td>
<td className="py-1.5">
<span className={`rounded px-1.5 py-0.5 text-xs ${log.success ? 'bg-gray-100 text-gray-700' : 'bg-red-50 text-red-600'}`}>
{log.success ? '成功' : '失败'}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
{logs.length > pageSize && (
<div className="mt-3 flex items-center justify-between border-t border-gray-100 pt-3">
<span className="text-xs text-gray-400"> {logs.length} {logPage}/{totalPages} </span>
<div className="flex items-center gap-1">
<button onClick={() => setLogPage(1)} disabled={logPage === 1} className="rounded border border-gray-200 px-2 py-1 text-xs hover:bg-gray-50 disabled:opacity-30"></button>
<button onClick={() => setLogPage(p => Math.max(1, p - 1))} disabled={logPage === 1} className="rounded border border-gray-200 px-2 py-1 text-xs hover:bg-gray-50 disabled:opacity-30"></button>
<button onClick={() => setLogPage(p => Math.min(totalPages, p + 1))} disabled={logPage >= totalPages} className="rounded border border-gray-200 px-2 py-1 text-xs hover:bg-gray-50 disabled:opacity-30"></button>
<button onClick={() => setLogPage(totalPages)} disabled={logPage >= totalPages} className="rounded border border-gray-200 px-2 py-1 text-xs hover:bg-gray-50 disabled:opacity-30"></button>
</div>
</div>
)}
</>
)}
</div>
)
}
function formatUA(ua: string): string {
// Extract browser/OS info from UA string
const parts: string[] = []
if (ua.includes('Windows NT')) parts.push('Windows')
else if (ua.includes('Mac OS X')) parts.push('macOS')
+142 -2
View File
@@ -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()}`
}
+365 -16
View File
@@ -8,6 +8,8 @@ ACTION="${1:-install}"
ACTION_CONFIRM="${2:-}"
ISSUE_URL="https://github.com/${REPO}/issues"
LOG_FILE="${CLICD_LOG_FILE:-/var/log/clicd-install.log}"
INSTALL_DOWNLOAD_MARKER="${CLICD_INSTALL_DOWNLOAD_MARKER:-/tmp/clicd-install-dir.$$}"
LIBVIRT_DEFAULT_MARKER="/var/lib/clicd/kvm/default-network.created"
echo "====================================="
echo " CLICD 中文安装/卸载脚本"
@@ -54,7 +56,7 @@ run_step() {
step_name="$1"
shift
log "开始:$step_name"
if "$@" >> "$LOG_FILE" 2>&1; then
if ( "$@" ) >> "$LOG_FILE" 2>&1; then
log "完成:$step_name"
return 0
fi
@@ -136,6 +138,7 @@ usage() {
示例:
curl -fsSL https://raw.githubusercontent.com/${REPO}/main/install.sh | sudo sh
curl -fsSL https://raw.githubusercontent.com/${REPO}/main/install.sh | sudo sh -s -- uninstall
curl -fsSL https://raw.githubusercontent.com/${REPO}/main/install.sh | sudo sh -s -- uninstall --yes
日志:${LOG_FILE}
问题反馈:${ISSUE_URL}
@@ -218,6 +221,39 @@ remove_lxc_container_dir() {
log "已删除 $container_dir"
}
remove_clicd_lxc_image_cache() {
log "正在删除 CLICD 使用的 LXC 镜像缓存..."
for container_dir in /var/lib/lxc/clicd-img-dl-*; do
[ -d "$container_dir" ] || continue
remove_lxc_container_dir "$container_dir"
done
for image in \
"ubuntu noble amd64" \
"ubuntu jammy amd64" \
"debian bookworm amd64" \
"debian bullseye amd64" \
"alpine 3.21 amd64" \
"centos 9-Stream amd64" \
"archlinux current amd64" \
"fedora 44 amd64" \
"rockylinux 10 amd64"
do
set -- $image
distro="$1"
release="$2"
arch="$3"
cache_dir="/var/cache/lxc/download/$distro/$release/$arch"
remove_path "$cache_dir"
rmdir "/var/cache/lxc/download/$distro/$release" >/dev/null 2>&1 || true
rmdir "/var/cache/lxc/download/$distro" >/dev/null 2>&1 || true
done
rmdir /var/cache/lxc/download >/dev/null 2>&1 || true
rmdir /var/cache/lxc >/dev/null 2>&1 || true
}
remove_kvm_domain() {
domain="$1"
case "$domain" in
@@ -258,6 +294,48 @@ destroy_clicd_kvm_domains() {
done
}
domain_is_clicd_kvm() {
domain="$1"
case "$domain" in
vm-[0-9]*)
return 0
;;
esac
virsh dumpxml "$domain" 2>/dev/null | grep -q '/var/lib/clicd/kvm/'
}
libvirt_default_used_by_non_clicd_domain() {
if ! has_cmd virsh; then
return 1
fi
for domain in $(virsh list --all --name 2>/dev/null); do
[ -n "$domain" ] || continue
if domain_is_clicd_kvm "$domain"; then
continue
fi
if virsh domiflist "$domain" 2>/dev/null | awk '$3 == "default" || $3 == "virbr0" {found = 1} END {exit found ? 0 : 1}'; then
return 0
fi
done
return 1
}
remove_clicd_libvirt_default_network() {
if ! has_cmd virsh || [ ! -f "$LIBVIRT_DEFAULT_MARKER" ]; then
return
fi
if libvirt_default_used_by_non_clicd_domain; then
warn "检测到非 CLICD 虚拟机仍在使用 libvirt default 网络,已保留 default/virbr0。"
return
fi
log "正在删除 CLICD 创建的 libvirt default NAT 网络..."
virsh net-destroy default >/dev/null 2>&1 || true
virsh net-undefine default >/dev/null 2>&1 || true
rm -f "$LIBVIRT_DEFAULT_MARKER"
}
delete_iptables_lines() {
table="$1"
chain="$2"
@@ -295,6 +373,143 @@ delete_filter_rule() {
done
}
delete_ip6_filter_rule() {
if ! has_cmd ip6tables; then
return
fi
while ip6tables -D "$@" >/dev/null 2>&1; do
:
done
}
delete_ip6tables_nat_source() {
source="$1"
if ! has_cmd ip6tables || [ -z "$source" ]; then
return
fi
while :; do
rule="$(
ip6tables -t nat -S POSTROUTING 2>/dev/null |
grep -F -- "-s $source" |
grep -F -- " -j MASQUERADE" |
sed 's/^-A /-D /' |
head -n 1
)"
[ -n "$rule" ] || break
# shellcheck disable=SC2086
ip6tables -t nat $rule >/dev/null 2>&1 || break
done
}
read_clicd_network_records() {
db="/root/.clicd/config.db"
legacy="/root/.clicd/config.json"
query="SELECT COALESCE(virtualization,''), COALESCE(ipv6,''), COALESCE(ipv6_interface,''), COALESCE(mac_address,'') FROM containers WHERE COALESCE(ipv6,'') <> '' OR COALESCE(mac_address,'') <> '';"
if [ -f "$db" ] && has_cmd sqlite3; then
sqlite3 -separator '|' "$db" "$query" 2>/dev/null || true
elif [ -f "$db" ] && has_cmd python3; then
CLICD_DB="$db" python3 - <<'PY' 2>/dev/null || true
import os
import sqlite3
db = os.environ.get("CLICD_DB")
for row in sqlite3.connect(db).execute(
"SELECT COALESCE(virtualization,''), COALESCE(ipv6,''), COALESCE(ipv6_interface,''), COALESCE(mac_address,'') "
"FROM containers WHERE COALESCE(ipv6,'') <> '' OR COALESCE(mac_address,'') <> ''"
):
print("|".join("" if value is None else str(value) for value in row))
PY
fi
if [ -f "$legacy" ] && has_cmd python3; then
CLICD_LEGACY_CONFIG="$legacy" python3 - <<'PY' 2>/dev/null || true
import json
import os
path = os.environ.get("CLICD_LEGACY_CONFIG")
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
for item in data.get("containers", []):
virt = item.get("virtualization", "")
ipv6 = item.get("ipv6", "")
uplink = item.get("ipv6_interface", "")
mac = item.get("mac_address", "")
if ipv6 or mac:
print("|".join(str(value or "") for value in (virt, ipv6, uplink, mac)))
PY
fi
}
cleanup_clicd_ipv6_record() {
virt="$1"
ipv6="$2"
uplink="$3"
mac="$4"
bridge="lxcbr0"
if [ "$virt" = "kvm" ]; then
bridge="virbr0"
fi
mac="$(printf '%s' "$mac" | tr '[:upper:]' '[:lower:]')"
if [ -n "$mac" ] && [ "$bridge" = "virbr0" ]; then
delete_ip6_filter_rule FORWARD -i "$bridge" -m mac --mac-source "$mac" -j DROP
fi
[ -n "$ipv6" ] || return
addr="${ipv6%%/*}"
source="$ipv6"
case "$source" in
*/*) ;;
*) source="$source/128" ;;
esac
delete_ip6tables_nat_source "$source"
delete_ip6_filter_rule FORWARD -i "$bridge" -s "$source" -j ACCEPT
delete_ip6_filter_rule FORWARD -o "$bridge" -d "$source" -j ACCEPT
if [ -n "$mac" ] && [ "$bridge" = "virbr0" ]; then
delete_ip6_filter_rule FORWARD -i "$bridge" -m mac --mac-source "$mac" -s "$source" -j ACCEPT
delete_ip6_filter_rule FORWARD -i "$bridge" -m mac --mac-source "$mac" -j DROP
fi
if has_cmd ip; then
ip -6 route del "$source" dev "$bridge" >/dev/null 2>&1 || true
if [ -n "$uplink" ]; then
ip -6 neigh del proxy "$addr" dev "$uplink" >/dev/null 2>&1 || true
fi
fi
}
cleanup_clicd_ipv6_from_config() {
read_clicd_network_records | while IFS='|' read -r virt ipv6 uplink mac; do
cleanup_clicd_ipv6_record "$virt" "$ipv6" "$uplink" "$mac"
done
}
cleanup_clicd_ipv6_bridge_routes() {
if ! has_cmd ip; then
return
fi
for bridge in lxcbr0 virbr0; do
ip -6 route show dev "$bridge" 2>/dev/null | awk '$1 ~ /\/128$/ {print $1}' | while IFS= read -r source; do
[ -n "$source" ] || continue
addr="${source%%/*}"
delete_ip6tables_nat_source "$source"
delete_ip6_filter_rule FORWARD -i "$bridge" -s "$source" -j ACCEPT
delete_ip6_filter_rule FORWARD -o "$bridge" -d "$source" -j ACCEPT
ip -6 neigh show proxy 2>/dev/null | awk -v addr="$addr" '$1 == addr {for (i = 1; i < NF; i++) if ($i == "dev") print $(i + 1)}' | while IFS= read -r uplink; do
[ -n "$uplink" ] || continue
ip -6 neigh del proxy "$addr" dev "$uplink" >/dev/null 2>&1 || true
done
ip -6 route del "$source" dev "$bridge" >/dev/null 2>&1 || true
done
ip -6 addr del fe80::1/64 dev "$bridge" >/dev/null 2>&1 || true
done
}
delete_ip6tables_bridge_rules() {
if ! has_cmd ip6tables; then
return
@@ -315,6 +530,8 @@ cleanup_clicd_networking() {
delete_iptables_lines nat PREROUTING 'clicd-'
delete_iptables_rule nat POSTROUTING -s 10.0.3.0/24 -o eth+ -j MASQUERADE
delete_iptables_rule nat POSTROUTING -s 192.168.122.0/24 -o eth+ -j MASQUERADE
cleanup_clicd_ipv6_from_config
cleanup_clicd_ipv6_bridge_routes
for bridge in lxcbr0 virbr0; do
delete_filter_rule FORWARD -i "$bridge" -j ACCEPT
@@ -382,10 +599,12 @@ confirm_uninstall() {
fi
echo ""
echo "[clicd][警告] 卸载会停止并删除 CLICD 服务、配置数据库、CLICD 创建的 LXC/KVM 实例和缓存数据。" >&2
echo "[clicd][警告] 为避免误删生产数据,脚本只会删除名称形如 ct-数字 的 LXC 容器和 vm-数字 的 KVM 域。" >&2
echo "[clicd][警告] 为避免误删生产数据,脚本只会删除名称形如 ct-数字 的 LXC 容器、clicd-img-dl-* 下载临时容器和 vm-数字 的 KVM 域。" >&2
echo "如需确认卸载,请输入:YES" >&2
if [ -t 0 ]; then
read answer
if [ -r /dev/tty ]; then
IFS= read -r answer < /dev/tty
elif [ -t 0 ]; then
IFS= read -r answer
else
answer=""
fi
@@ -415,7 +634,9 @@ uninstall_clicd() {
[ -d "$container_dir" ] || continue
remove_lxc_container_dir "$container_dir"
done
remove_clicd_lxc_image_cache
destroy_clicd_kvm_domains
remove_clicd_libvirt_default_network
cleanup_clicd_networking
remove_clicd_host_hooks
remove_clicd_quota_records
@@ -430,7 +651,7 @@ uninstall_clicd() {
# /var/lib/lxc 可能包含非 CLICD 容器,生产环境不整体删除。
unmount_path_tree /var/lib/clicd
remove_path /var/lib/clicd
# /var/cache/lxc 是 LXC 全局镜像缓存,可能被其他工具复用,生产环境不整体删除。
# /var/cache/lxc 是 LXC 全局缓存,已按 CLICD 模板精确清理,生产环境不整体删除。
remove_path /var/cache/clicd
warn "保留 /root/clicd-backups,避免误删部署/回滚备份。确认不需要后可手动删除。"
remove_clicd_tmp_files
@@ -450,7 +671,7 @@ uninstall_clicd() {
echo "====================================="
echo " 已删除服务、二进制、SQLite/配置数据、CLICD LXC/KVM 实例、"
echo " CLICD 镜像缓存、防火墙规则、主机钩子、配额记录和临时文件。"
echo " 已保留 /root/clicd-backups 和 LXC 全局缓存,避免误删生产备份/共享镜像。"
echo " 已保留 /root/clicd-backups 和非 CLICD 的 LXC 全局缓存,避免误删生产备份/共享镜像。"
echo " 日志:$LOG_FILE"
echo " 问题反馈:$ISSUE_URL"
echo "====================================="
@@ -766,6 +987,8 @@ setup_default_libvirt_network() {
EOF
virsh net-define "$net_xml"
rm -f "$net_xml"
mkdir -p "$(dirname "$LIBVIRT_DEFAULT_MARKER")"
touch "$LIBVIRT_DEFAULT_MARKER"
fi
if ! libvirt_network_active; then
virsh net-start default
@@ -784,6 +1007,12 @@ setup_subids() {
grep -q '^root:' /etc/subgid 2>/dev/null || echo 'root:100000:65536' >> /etc/subgid
}
configure_lxc_storage_access() {
log "Configuring LXC storage directory permissions..."
mkdir -p /var/lib/lxc
chmod 755 /var/lib/lxc
}
try_enable_project_quota() {
root_src="$(findmnt -no SOURCE / 2>/dev/null || true)"
root_fs="$(findmnt -no FSTYPE / 2>/dev/null || true)"
@@ -819,6 +1048,59 @@ try_enable_project_quota() {
log "ext4 project quota 未启用,CLICD 将自动回退到 loopback 镜像磁盘限制模式。"
}
download_file() {
url="$1"
dest="$2"
rm -f "$dest"
if has_cmd curl; then
curl -fL --retry 6 --retry-delay 2 --connect-timeout 20 --max-time 600 "$url" -o "$dest"
return
fi
if has_cmd wget; then
wget --tries=6 --timeout=30 --waitretry=2 -O "$dest" "$url"
return
fi
return 127
}
release_api_json() {
api_url="https://api.github.com/repos/${REPO}/releases/latest"
if has_cmd curl; then
curl -fsSL --retry 3 --retry-delay 2 --connect-timeout 20 --max-time 120 "$api_url" 2>/dev/null || true
return
fi
if has_cmd wget; then
wget -qO- --tries=3 --timeout=30 "$api_url" 2>/dev/null || true
return
fi
}
release_asset_url() {
asset_name="$1"
if [ "$CLICD_INSTALL_VERSION" != "latest" ]; then
printf '%s\n' "https://github.com/${REPO}/releases/download/${CLICD_INSTALL_VERSION}/${asset_name}"
return
fi
api_data="$(release_api_json)"
url="$(printf '%s\n' "$api_data" | sed -n 's/.*"browser_download_url": *"\([^"]*\/'"$asset_name"'\)".*/\1/p' | head -n 1)"
if [ -n "$url" ]; then
printf '%s\n' "$url"
return
fi
tag="$(printf '%s\n' "$api_data" | sed -n 's/.*"tag_name": *"\([^"]*\)".*/\1/p' | head -n 1)"
if [ -n "$tag" ]; then
printf '%s\n' "https://github.com/${REPO}/releases/download/${tag}/${asset_name}"
return
fi
printf '%s\n' "https://github.com/${REPO}/releases/latest/download/${asset_name}"
}
download_release_if_needed() {
if [ -f "./clicd" ]; then
return
@@ -834,19 +1116,66 @@ download_release_if_needed() {
log "正在下载发行版包:${download_url}"
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' 0
rm -f "$INSTALL_DOWNLOAD_MARKER"
printf '%s\n' "$tmp_dir" > "$INSTALL_DOWNLOAD_MARKER" || die "Failed to write install temp marker."
if has_cmd curl; then
curl -fL "$download_url" -o "$tmp_dir/$ASSET"
elif has_cmd wget; then
wget -O "$tmp_dir/$ASSET" "$download_url"
else
if ! has_cmd curl && ! has_cmd wget; then
die "下载发行版包需要 curl 或 wget。"
fi
tar -xzf "$tmp_dir/$ASSET" -C "$tmp_dir"
cd "$tmp_dir/clicd-linux-amd64"
[ -f "./clicd" ] || die "下载的发行版包中未找到 clicd 二进制。"
archive_path="$tmp_dir/$ASSET"
archive_urls="$download_url"
resolved_archive_url="$(release_asset_url "$ASSET")"
if [ "$resolved_archive_url" != "$download_url" ]; then
archive_urls="$archive_urls $resolved_archive_url"
fi
archive_ok=0
for url in $archive_urls; do
[ -n "$url" ] || continue
log "Trying release archive: $url"
if download_file "$url" "$archive_path" && [ -s "$archive_path" ]; then
archive_ok=1
break
fi
warn "Release archive download failed, trying next source: $url"
done
if [ "$archive_ok" = "1" ]; then
tar -xzf "$archive_path" -C "$tmp_dir" || die "Failed to extract release package: $archive_path"
else
binary_asset="clicd-linux-amd64"
if [ "$CLICD_INSTALL_VERSION" = "latest" ]; then
binary_url="https://github.com/${REPO}/releases/latest/download/${binary_asset}"
else
binary_url="https://github.com/${REPO}/releases/download/${CLICD_INSTALL_VERSION}/${binary_asset}"
fi
binary_urls="$binary_url"
resolved_binary_url="$(release_asset_url "$binary_asset")"
if [ "$resolved_binary_url" != "$binary_url" ]; then
binary_urls="$binary_urls $resolved_binary_url"
fi
binary_path="$tmp_dir/$binary_asset"
binary_ok=0
for url in $binary_urls; do
[ -n "$url" ] || continue
log "Trying release binary: $url"
if download_file "$url" "$binary_path" && [ -s "$binary_path" ]; then
mkdir -p "$tmp_dir/clicd-linux-amd64"
cp "$binary_path" "$tmp_dir/clicd-linux-amd64/clicd"
chmod +x "$tmp_dir/clicd-linux-amd64/clicd"
binary_ok=1
break
fi
warn "Release binary download failed, trying next source: $url"
done
[ "$binary_ok" = "1" ] || die "Release package download failed: $download_url"
fi
[ -d "$tmp_dir/clicd-linux-amd64" ] || die "Release package layout is invalid: missing clicd-linux-amd64 directory"
[ -f "$tmp_dir/clicd-linux-amd64/clicd" ] || die "下载的发行版包中未找到 clicd 二进制。"
}
install_binary() {
@@ -857,12 +1186,31 @@ install_binary() {
rc-service clicd stop >/dev/null 2>&1 || true
fi
bin_src="./clicd"
download_dir=""
if [ ! -f "$bin_src" ] && [ -f "$INSTALL_DOWNLOAD_MARKER" ]; then
download_dir="$(sed -n '1p' "$INSTALL_DOWNLOAD_MARKER" 2>/dev/null || true)"
if [ -n "$download_dir" ] && [ -f "$download_dir/clicd-linux-amd64/clicd" ]; then
bin_src="$download_dir/clicd-linux-amd64/clicd"
fi
fi
[ -f "$bin_src" ] || die "未找到 clicd 二进制,安装无法继续。"
tmp_bin="/usr/local/bin/clicd.new.$$"
cp ./clicd "$tmp_bin"
cp "$bin_src" "$tmp_bin"
chmod +x "$tmp_bin"
mv -f "$tmp_bin" /usr/local/bin/clicd
chmod +x /usr/local/bin/clicd
log "已安装二进制:/usr/local/bin/clicd"
if [ -n "$download_dir" ]; then
case "$download_dir" in
/tmp/*)
rm -rf "$download_dir"
;;
esac
rm -f "$INSTALL_DOWNLOAD_MARKER"
fi
}
install_systemd_service() {
@@ -967,6 +1315,7 @@ run_step "配置内核网络参数" configure_kernel_networking
run_step "配置运行时服务" setup_runtime_services
run_step "配置 libvirt default NAT 网络" setup_default_libvirt_network
run_step "配置 UID/GID 映射" setup_subids
run_step "Configure LXC storage permissions" configure_lxc_storage_access
run_step "检查 project quota" try_enable_project_quota
run_step "下载发行版包" download_release_if_needed
run_step "安装 CLICD 二进制" install_binary