mirror of
https://github.com/MengMengCode/CLICD.git
synced 2026-08-05 05:36:07 +08:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a923daa7a2 | |||
| c46f84c66e | |||
| 835bb51c6e | |||
| 7aed51e86b | |||
| e364807fb9 | |||
| c63ce02709 | |||
| 54f9ed7f7d | |||
| b01f9fe301 | |||
| d03e2c4c0c | |||
| 33603f5776 | |||
| 9ad7bcc97a | |||
| f3a1687a18 |
Submodule .claude/worktrees/agent-ae3871aebda20eb86 deleted from 422e48b524
@@ -0,0 +1,65 @@
|
||||
name: Deploy Docs
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
paths:
|
||||
- "docs/**"
|
||||
- ".github/workflows/pages.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: github-pages
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build VitePress
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: npm
|
||||
cache-dependency-path: docs/package-lock.json
|
||||
|
||||
- name: Setup Pages
|
||||
uses: actions/configure-pages@v5
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: docs
|
||||
run: npm ci
|
||||
|
||||
- name: Build docs
|
||||
working-directory: docs
|
||||
env:
|
||||
VITEPRESS_BASE: /
|
||||
run: npm run build
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: docs/.vitepress/dist
|
||||
|
||||
deploy:
|
||||
name: Deploy GitHub Pages
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
+1
-1
@@ -58,7 +58,7 @@ backend/tmp/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
*.claude/
|
||||
.claude/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -38,6 +38,19 @@ func HandleContainers(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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/v1/containers/")
|
||||
|
||||
@@ -766,14 +766,15 @@ func detectDiskSMART(path string) HostDiskSMARTProbe {
|
||||
smart.PowerOnHours = data.PowerOnTime.Hours
|
||||
smart.PowerCycleCount = data.PowerCycleCount
|
||||
if data.NVMe.PowerOnHours > 0 {
|
||||
smart.PowerOnHours = int64(data.NVMe.PowerOnHours)
|
||||
smart.PowerOnHours = uint64ToInt64(data.NVMe.PowerOnHours)
|
||||
}
|
||||
if data.NVMe.PowerCycles > 0 {
|
||||
smart.PowerCycleCount = int64(data.NVMe.PowerCycles)
|
||||
smart.PowerCycleCount = uint64ToInt64(data.NVMe.PowerCycles)
|
||||
}
|
||||
if data.NVMe.PercentageUsed > 0 {
|
||||
used := int(data.NVMe.PercentageUsed)
|
||||
smart.LifeUsedPercent = &used
|
||||
if used, ok := smartPercentToInt(data.NVMe.PercentageUsed); ok {
|
||||
smart.LifeUsedPercent = &used
|
||||
}
|
||||
}
|
||||
smart.ReadDataBytes = data.NVMe.DataUnitsRead * 512000
|
||||
smart.WrittenDataBytes = data.NVMe.DataUnitsWritten * 512000
|
||||
@@ -829,11 +830,15 @@ func parseATAAttributes(smart *HostDiskSMARTProbe, attrs []smartctlAttribute) {
|
||||
switch name {
|
||||
case "poweronhours":
|
||||
if smart.PowerOnHours == 0 {
|
||||
smart.PowerOnHours = int64(raw)
|
||||
if parsed, ok := smartAttrRawInt64(attr); ok {
|
||||
smart.PowerOnHours = parsed
|
||||
}
|
||||
}
|
||||
case "powercyclecount":
|
||||
if smart.PowerCycleCount == 0 {
|
||||
smart.PowerCycleCount = int64(raw)
|
||||
if parsed, ok := smartAttrRawInt64(attr); ok {
|
||||
smart.PowerCycleCount = parsed
|
||||
}
|
||||
}
|
||||
case "totallbaswritten":
|
||||
if smart.WrittenDataBytes == 0 {
|
||||
@@ -868,7 +873,9 @@ func parseATAAttributes(smart *HostDiskSMARTProbe, attrs []smartctlAttribute) {
|
||||
if smart.LifeUsedPercent == nil {
|
||||
remaining := attr.Value
|
||||
if raw > 0 && raw <= 100 {
|
||||
remaining = int(raw)
|
||||
if parsed, ok := smartPercentToInt(raw); ok {
|
||||
remaining = parsed
|
||||
}
|
||||
}
|
||||
used := 100 - remaining
|
||||
if used < 0 {
|
||||
@@ -880,8 +887,9 @@ func parseATAAttributes(smart *HostDiskSMARTProbe, attrs []smartctlAttribute) {
|
||||
}
|
||||
case "percentageused":
|
||||
if smart.LifeUsedPercent == nil && raw <= 255 {
|
||||
used := int(raw)
|
||||
smart.LifeUsedPercent = &used
|
||||
if used, ok := smartPercentToInt(raw); ok {
|
||||
smart.LifeUsedPercent = &used
|
||||
}
|
||||
}
|
||||
case "erasefailcounttotal", "erasecount", "nandwrites", "programfailcnttotal":
|
||||
if smart.EraseCount == "" && rawText != "" {
|
||||
@@ -893,6 +901,20 @@ func parseATAAttributes(smart *HostDiskSMARTProbe, attrs []smartctlAttribute) {
|
||||
}
|
||||
}
|
||||
|
||||
func uint64ToInt64(value uint64) int64 {
|
||||
if value > 9223372036854775807 {
|
||||
return 0
|
||||
}
|
||||
return int64(value)
|
||||
}
|
||||
|
||||
func smartPercentToInt(value uint64) (int, bool) {
|
||||
if value > 2147483647 {
|
||||
return 0, false
|
||||
}
|
||||
return int(value), true
|
||||
}
|
||||
|
||||
func normalizeSMARTAttrName(name string) string {
|
||||
name = strings.ToLower(name)
|
||||
var b strings.Builder
|
||||
@@ -914,6 +936,22 @@ func smartAttrRawText(attr smartctlAttribute) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func smartAttrRawInt64(attr smartctlAttribute) (int64, bool) {
|
||||
text := smartAttrRawText(attr)
|
||||
if value, err := strconv.ParseInt(text, 10, 64); err == nil {
|
||||
return value, true
|
||||
}
|
||||
digits := firstUintText(text)
|
||||
if digits == "" {
|
||||
return 0, false
|
||||
}
|
||||
value, err := strconv.ParseInt(digits, 10, 64)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return value, true
|
||||
}
|
||||
|
||||
func smartAttrRawUint(attr smartctlAttribute) uint64 {
|
||||
text := smartAttrRawText(attr)
|
||||
if value, err := strconv.ParseUint(text, 10, 64); err == nil {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -205,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
|
||||
@@ -302,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
|
||||
}
|
||||
@@ -318,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
|
||||
}
|
||||
@@ -371,51 +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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -374,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
|
||||
@@ -466,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,
|
||||
@@ -477,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"),
|
||||
}
|
||||
|
||||
+180
-52
@@ -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
|
||||
}
|
||||
@@ -1650,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()
|
||||
@@ -1662,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 != "" {
|
||||
@@ -1680,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))
|
||||
@@ -1694,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()
|
||||
@@ -1701,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.
|
||||
@@ -1827,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
|
||||
@@ -1912,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()
|
||||
@@ -1935,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 {
|
||||
@@ -1945,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
|
||||
}
|
||||
|
||||
@@ -1961,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")
|
||||
@@ -1993,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
|
||||
}
|
||||
|
||||
@@ -2235,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)
|
||||
@@ -2258,28 +2405,10 @@ func (m *Manager) ReinstallContainer(id int, templateID string) error {
|
||||
// Clean port mappings temporarily
|
||||
m.CleanPortMappings(id)
|
||||
|
||||
// Destroy old LXC but keep config. lxc-destroy can leave the config
|
||||
// directory behind when rootfs mounts are still present, which makes the
|
||||
// following lxc-create fail with "Container already exists".
|
||||
exec.Command("lxc-stop", "-n", lxcName, "-k").Run()
|
||||
rootfs := filepath.Join(m.LxcPath, lxcName, "rootfs")
|
||||
exec.Command("umount", "-R", "-l", rootfs).Run()
|
||||
exec.Command("lxc-destroy", "-n", lxcName, "-f").Run()
|
||||
exec.Command("umount", "-R", "-l", rootfs).Run()
|
||||
os.RemoveAll(filepath.Join(m.LxcPath, lxcName))
|
||||
|
||||
// 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 {
|
||||
@@ -2319,14 +2448,13 @@ func (m *Manager) ReinstallContainer(id int, templateID string) error {
|
||||
if c.SSHPassword == "" {
|
||||
c.SSHPassword = generateRandomString(16)
|
||||
}
|
||||
if err := m.preconfigureSSH(rootfsPath, c.SSHPassword, templateID); err != nil {
|
||||
if err := m.preconfigureSSH(rootfsPath, templateID); err != nil {
|
||||
fmt.Printf("Warning: failed to pre-configure SSH in %s after reinstall: %v\n", lxcName, err)
|
||||
}
|
||||
if err := m.shiftRootfsForUnprivileged(lxcName); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := m.runRootfsCommand(rootfsPath,
|
||||
"sh", "-c", fmt.Sprintf("printf '%%s:%%s\\n' root %s | chpasswd", shellQuote(c.SSHPassword))); err != nil {
|
||||
if err := m.setRootfsPassword(rootfsPath, c.SSHPassword); err != nil {
|
||||
fmt.Printf("Warning: failed to set root password in %s after reinstall: %v\n", lxcName, err)
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRootfsCommandAddsSeparatorAndPreservesArgs(t *testing.T) {
|
||||
func TestRootfsCommandAddsSeparatorForAllowedCommand(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
rootfs := filepath.Join(base, "ct-1", "rootfs")
|
||||
if err := os.MkdirAll(rootfs, 0755); err != nil {
|
||||
@@ -16,18 +16,31 @@ func TestRootfsCommandAddsSeparatorAndPreservesArgs(t *testing.T) {
|
||||
}
|
||||
|
||||
m := &Manager{LxcPath: base}
|
||||
cmd, err := m.rootfsCommand(rootfs, "sh", "-c", "true", "--flag")
|
||||
cmd, err := m.rootfsCommand(rootfs, "chpasswd")
|
||||
if err != nil {
|
||||
t.Fatalf("rootfsCommand returned error: %v", err)
|
||||
}
|
||||
|
||||
want := []string{"chroot", "--", rootfs, "sh", "-c", "true", "--flag"}
|
||||
want := []string{"chroot", "--", rootfs, "chpasswd"}
|
||||
if !reflect.DeepEqual(cmd.Args, want) {
|
||||
t.Fatalf("cmd.Args = %#v, want %#v", cmd.Args, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootfsCommandAllowsLeadingDashContainerName(t *testing.T) {
|
||||
func TestRootfsCommandRejectsUnmanagedCommand(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
rootfs := filepath.Join(base, "ct-1", "rootfs")
|
||||
if err := os.MkdirAll(rootfs, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
m := &Manager{LxcPath: base}
|
||||
if _, err := m.rootfsCommand(rootfs, "true"); err == nil {
|
||||
t.Fatal("rootfsCommand allowed unmanaged command")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootfsCommandRejectsLeadingDashContainerName(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
rootfs := filepath.Join(base, "-ct", "rootfs")
|
||||
if err := os.MkdirAll(rootfs, 0755); err != nil {
|
||||
@@ -35,14 +48,8 @@ func TestRootfsCommandAllowsLeadingDashContainerName(t *testing.T) {
|
||||
}
|
||||
|
||||
m := &Manager{LxcPath: base}
|
||||
cmd, err := m.rootfsCommand(rootfs, "true")
|
||||
if err != nil {
|
||||
t.Fatalf("rootfsCommand returned error: %v", err)
|
||||
}
|
||||
|
||||
want := []string{"chroot", "--", rootfs, "true"}
|
||||
if !reflect.DeepEqual(cmd.Args, want) {
|
||||
t.Fatalf("cmd.Args = %#v, want %#v", cmd.Args, want)
|
||||
if _, err := m.rootfsCommand(rootfs, "chpasswd"); err == nil {
|
||||
t.Fatal("rootfsCommand allowed leading-dash container name")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,7 +71,7 @@ func TestRootfsCommandRejectsUnsafeRootfsPaths(t *testing.T) {
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if _, err := m.rootfsCommand(tc.path, "true"); err == nil {
|
||||
if _, err := m.rootfsCommand(tc.path, "chpasswd"); err == nil {
|
||||
t.Fatalf("rootfsCommand(%q) returned nil error", tc.path)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"clicd/internal/api"
|
||||
@@ -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)))
|
||||
@@ -117,6 +121,7 @@ func setupRoutes(mux *http.ServeMux) {
|
||||
// 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)))
|
||||
@@ -139,6 +144,7 @@ func setupRoutes(mux *http.ServeMux) {
|
||||
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))))
|
||||
@@ -206,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 +1 @@
|
||||
?
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package version
|
||||
|
||||
var (
|
||||
Version = "1.1.5"
|
||||
Version = "1.1.7"
|
||||
Repo = "MengMengCode/CLICD"
|
||||
)
|
||||
|
||||
|
||||
@@ -50,6 +50,7 @@ func main() {
|
||||
|
||||
// Start security scanner
|
||||
api.InitScanner()
|
||||
api.StartSSLRenewalMonitor()
|
||||
|
||||
// Ensure iptables FORWARD rules allow managed bridge traffic.
|
||||
lxc.EnsureForwardRules("lxcbr0")
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
.vitepress/cache/
|
||||
.vitepress/dist/
|
||||
.vitepress/.temp/
|
||||
@@ -0,0 +1,74 @@
|
||||
import { defineConfig } from 'vitepress'
|
||||
|
||||
export default defineConfig({
|
||||
title: 'CLICD',
|
||||
description: '面向 LXC/KVM 的轻量虚拟化管理面板文档',
|
||||
lang: 'zh-CN',
|
||||
base: process.env.VITEPRESS_BASE || '/',
|
||||
cleanUrls: true,
|
||||
ignoreDeadLinks: true,
|
||||
head: [
|
||||
['link', { rel: 'icon', href: '/favicon.svg' }],
|
||||
],
|
||||
themeConfig: {
|
||||
logo: '/favicon.svg',
|
||||
search: {
|
||||
provider: 'local',
|
||||
},
|
||||
nav: [
|
||||
{ text: '指南', link: '/guide/introduction' },
|
||||
{ text: '功能', link: '/features/dashboard' },
|
||||
{ text: '运维', link: '/operations/deployment' },
|
||||
{ text: '开发', link: '/developer/architecture' },
|
||||
],
|
||||
sidebar: [
|
||||
{
|
||||
text: '开始',
|
||||
items: [
|
||||
{ text: '项目介绍', link: '/guide/introduction' },
|
||||
{ text: '安装', link: '/guide/installation' },
|
||||
{ text: '升级', link: '/guide/upgrade' },
|
||||
{ text: '快速上手', link: '/guide/quick-start' },
|
||||
{ text: '配置说明', link: '/guide/configuration' },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: '功能',
|
||||
items: [
|
||||
{ text: '控制面板', link: '/features/dashboard' },
|
||||
{ text: '容器管理', link: '/features/containers' },
|
||||
{ text: '镜像管理', link: '/features/images' },
|
||||
{ text: '网络与路由', link: '/features/networking' },
|
||||
{ text: '快照管理', link: '/features/snapshots' },
|
||||
{ text: '安全告警', link: '/features/security' },
|
||||
{ text: '子用户', link: '/features/sub-users' },
|
||||
{ text: 'API 集成', link: '/features/api' },
|
||||
{ text: '主机报告', link: '/features/host-report' },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: '运维',
|
||||
items: [
|
||||
{ text: '部署建议', link: '/operations/deployment' },
|
||||
{ text: '故障排查', link: '/operations/troubleshooting' },
|
||||
{ text: '常见问题', link: '/operations/faq' },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: '开发',
|
||||
items: [
|
||||
{ text: '系统架构', link: '/developer/architecture' },
|
||||
{ text: '本地构建', link: '/developer/build' },
|
||||
{ text: '发布流程', link: '/developer/release' },
|
||||
],
|
||||
},
|
||||
],
|
||||
socialLinks: [
|
||||
{ icon: 'github', link: 'https://github.com/MengMengCode/CLICD' },
|
||||
],
|
||||
footer: {
|
||||
message: 'CLICD 文档面向部署、使用、运维和二次开发场景。',
|
||||
copyright: 'Copyright © CLICD contributors',
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,26 @@
|
||||
:root {
|
||||
--vp-c-brand-1: #0284c7;
|
||||
--vp-c-brand-2: #0ea5e9;
|
||||
--vp-c-brand-3: #7dd3fc;
|
||||
--vp-c-brand-soft: rgba(14, 165, 233, 0.14);
|
||||
--vp-home-hero-name-color: #0369a1;
|
||||
--vp-home-hero-image-background-image: linear-gradient(135deg, #7dd3fc 0%, #38bdf8 46%, #86efac 100%);
|
||||
--vp-home-hero-image-filter: blur(46px);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--vp-c-brand-1: #7dd3fc;
|
||||
--vp-c-brand-2: #38bdf8;
|
||||
--vp-c-brand-3: #0ea5e9;
|
||||
--vp-c-brand-soft: rgba(125, 211, 252, 0.16);
|
||||
--vp-home-hero-name-color: #bae6fd;
|
||||
}
|
||||
|
||||
.VPHomeHero .text {
|
||||
max-width: 680px;
|
||||
}
|
||||
|
||||
.vp-doc table {
|
||||
display: table;
|
||||
width: 100%;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import DefaultTheme from 'vitepress/theme'
|
||||
import './custom.css'
|
||||
|
||||
export default DefaultTheme
|
||||
@@ -0,0 +1,42 @@
|
||||
# 系统架构
|
||||
|
||||
CLICD 由 Go 后端、React 前端和宿主机虚拟化能力组成。
|
||||
|
||||
## 后端
|
||||
|
||||
后端入口在 `backend/main.go`,HTTP 服务路由集中在 `backend/internal/server/server.go`。主要模块:
|
||||
|
||||
- `internal/api`:Web 面板和 `/api/v1` 的 HTTP 接口。
|
||||
- `internal/config`:配置和 SQLite 存储。
|
||||
- `internal/lxc`:LXC 容器管理。
|
||||
- `internal/kvm`:KVM/libvirt 虚拟机管理。
|
||||
- `internal/cli`:命令行管理入口。
|
||||
- `internal/server`:静态前端嵌入和 HTTP 服务。
|
||||
- `internal/version`:版本号。
|
||||
|
||||
## 前端
|
||||
|
||||
前端入口在 `frontend/src/main.tsx`,页面位于 `frontend/src/pages`,通用组件位于 `frontend/src/components`。
|
||||
|
||||
主要页面:
|
||||
|
||||
- 控制面板:`Dashboard.tsx`
|
||||
- 容器列表:`Containers.tsx`
|
||||
- 容器详情:`ContainerDetail.tsx`
|
||||
- 镜像管理:`ImageManagement.tsx`
|
||||
- 安全告警:`Security.tsx`
|
||||
- 快照管理:`Snapshots.tsx`
|
||||
- 路由管理:`Routing.tsx`
|
||||
- API 集成:`ApiIntegration.tsx`
|
||||
- 主机报告:`HostReport.tsx`
|
||||
- 子用户管理:`SubUserManagement.tsx`
|
||||
|
||||
## 前端嵌入
|
||||
|
||||
生产构建时,前端产物会放入 `backend/internal/server/web`,后端通过 Go embed 提供静态文件,并对非 API 路由返回 SPA 入口。
|
||||
|
||||
## 接口分层
|
||||
|
||||
- `/api/*`:Web 面板和兼容接口。
|
||||
- `/api/v1/*`:推荐给外部自动化系统使用的版本化接口。
|
||||
- WebSSH 和 WebVNC 使用短期票据后建立 WebSocket 连接。
|
||||
@@ -0,0 +1,42 @@
|
||||
# 本地构建
|
||||
|
||||
## 前端构建
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
|
||||
构建输出位于 `frontend/dist`。
|
||||
|
||||
## 后端构建
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
go test ./...
|
||||
go build -o ../build/clicd .
|
||||
```
|
||||
|
||||
如果要打包嵌入式 Web 面板,需要先把前端构建产物同步到后端嵌入目录。
|
||||
|
||||
## 一键构建
|
||||
|
||||
项目根目录提供了构建脚本:
|
||||
|
||||
```bash
|
||||
bash build.sh
|
||||
```
|
||||
|
||||
该脚本用于串联前端构建、静态资源同步和 Go 二进制构建。
|
||||
|
||||
## 文档站构建
|
||||
|
||||
```bash
|
||||
cd docs
|
||||
npm install
|
||||
npm run dev
|
||||
npm run build
|
||||
```
|
||||
|
||||
`npm run dev` 用于本地预览,`npm run build` 用于生成静态文档。
|
||||
@@ -0,0 +1,44 @@
|
||||
# 发布流程
|
||||
|
||||
CLICD 的安装和升级依赖 GitHub Release 产物。发布时建议使用语义化版本标签,例如 `v1.1.6`。
|
||||
|
||||
## 版本号
|
||||
|
||||
版本号需要同步检查:
|
||||
|
||||
- `backend/internal/version/version.go`
|
||||
- `frontend/package.json`
|
||||
- Release 标签。
|
||||
|
||||
## Release 产物
|
||||
|
||||
安装脚本会优先下载 Linux AMD64 产物:
|
||||
|
||||
```text
|
||||
clicd-linux-amd64.tar.gz
|
||||
```
|
||||
|
||||
在部分场景中也会尝试下载单独二进制:
|
||||
|
||||
```text
|
||||
clicd-linux-amd64
|
||||
```
|
||||
|
||||
## 安装脚本行为
|
||||
|
||||
- `CLICD_VERSION=latest`:使用 GitHub `releases/latest`。
|
||||
- `CLICD_VERSION=vX.Y.Z`:下载指定标签的 Release 产物。
|
||||
|
||||
示例:
|
||||
|
||||
```bash
|
||||
CLICD_VERSION=v1.1.6 sh install.sh
|
||||
```
|
||||
|
||||
## 发布后验证
|
||||
|
||||
- 安装脚本可以下载新版本。
|
||||
- `systemctl status clicd` 正常。
|
||||
- `/api/version` 返回新版本。
|
||||
- Web 面板可以加载前端资源。
|
||||
- 容器列表、任务队列、API Key 页面可以正常打开。
|
||||
@@ -0,0 +1,136 @@
|
||||
# API 集成
|
||||
|
||||
CLICD 对外推荐使用 `/api/v1` 接口。旧版未带版本号的接口主要用于 Web 面板和兼容场景,新接入请优先使用 `/api/v1`。
|
||||
|
||||
## 认证
|
||||
|
||||
API Key 可在“API 集成”页面创建和管理。请求时支持两种写法:
|
||||
|
||||
```bash
|
||||
curl -H "X-API-Key: YOUR_API_KEY" https://panel.example.com/api/v1/containers
|
||||
```
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer YOUR_API_KEY" https://panel.example.com/api/v1/dashboard
|
||||
```
|
||||
|
||||
## Python 示例
|
||||
|
||||
```python
|
||||
import requests
|
||||
|
||||
BASE_URL = "https://panel.example.com"
|
||||
API_KEY = "YOUR_API_KEY"
|
||||
|
||||
session = requests.Session()
|
||||
session.headers.update({
|
||||
"X-API-Key": API_KEY,
|
||||
"Content-Type": "application/json",
|
||||
})
|
||||
|
||||
resp = session.get(f"{BASE_URL}/api/v1/containers", timeout=15)
|
||||
resp.raise_for_status()
|
||||
containers = resp.json()
|
||||
|
||||
print(containers)
|
||||
```
|
||||
|
||||
创建端口映射:
|
||||
|
||||
```python
|
||||
import requests
|
||||
|
||||
BASE_URL = "https://panel.example.com"
|
||||
API_KEY = "YOUR_API_KEY"
|
||||
CONTAINER_ID = "example-vm"
|
||||
|
||||
payload = {
|
||||
"name": "web",
|
||||
"protocol": "tcp",
|
||||
"host_port": 18080,
|
||||
"container_port": 80,
|
||||
}
|
||||
|
||||
resp = requests.post(
|
||||
f"{BASE_URL}/api/v1/containers/{CONTAINER_ID}/port-mappings",
|
||||
headers={"X-API-Key": API_KEY},
|
||||
json=payload,
|
||||
timeout=15,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
print(resp.json())
|
||||
```
|
||||
|
||||
## 返回结构示例
|
||||
|
||||
容器列表:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": [
|
||||
{
|
||||
"id": 5,
|
||||
"uuid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
|
||||
"name": "example-vm",
|
||||
"status": "running",
|
||||
"ip": "10.0.3.25",
|
||||
"ipv6": "2001:db8:100::1005",
|
||||
"cpu_limit": 2,
|
||||
"memory_limit": 2048,
|
||||
"disk_limit": 20480,
|
||||
"traffic_limit": 107374182400,
|
||||
"expires_at": "2026-12-31 23:59:59"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
任务队列:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": [
|
||||
{
|
||||
"id": "task-13",
|
||||
"type": "restart",
|
||||
"status": "running",
|
||||
"created_at": "2026-06-09T10:00:00+08:00"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
WebSSH 票据:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"ticket": "***60秒有效票据***"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 常用接口
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| GET | `/api/v1/dashboard` | 控制面板统计 |
|
||||
| GET | `/api/v1/host-info` | 主机资源 |
|
||||
| GET | `/api/v1/containers` | 容器列表 |
|
||||
| POST | `/api/v1/containers` | 创建容器 |
|
||||
| POST | `/api/v1/containers/{id}/start` | 开机 |
|
||||
| POST | `/api/v1/containers/{id}/stop` | 关机 |
|
||||
| POST | `/api/v1/containers/{id}/restart` | 重启 |
|
||||
| DELETE | `/api/v1/containers/{id}/delete` | 删除 |
|
||||
| GET | `/api/v1/tasks` | 任务队列 |
|
||||
| GET | `/api/v1/templates` | 模板列表 |
|
||||
| GET | `/api/v1/images` | 镜像管理列表 |
|
||||
| GET | `/api/v1/snapshots` | 快照总览 |
|
||||
| GET | `/api/v1/security/alerts` | 安全告警 |
|
||||
| GET | `/api/v1/audit-logs` | 操作日志 |
|
||||
| GET | `/api/v1/api-keys` | API Key 列表 |
|
||||
|
||||
完整接口清单请以面板内“API 集成”页面为准。
|
||||
@@ -0,0 +1,73 @@
|
||||
# 容器管理
|
||||
|
||||
容器管理是 CLICD 的核心模块,覆盖创建、生命周期控制、资源限制、网络映射、流量统计、密码重置和控制台访问。
|
||||
|
||||
## 容器列表
|
||||
|
||||
列表页用于扫描所有容器状态。管理员可以查看全部容器,子用户只能看到授权范围内的容器。
|
||||
|
||||
常见字段包括:
|
||||
|
||||
- ID、UUID、名称。
|
||||
- 虚拟化类型。
|
||||
- 运行状态。
|
||||
- IP、IPv6。
|
||||
- CPU、内存、磁盘限制。
|
||||
- 流量使用量和流量上限。
|
||||
- 到期时间。
|
||||
|
||||
## 创建容器
|
||||
|
||||
创建时需要选择模板,并设置资源配额。批量创建可以通过面板或 API 完成,适合一次性发放多个容器。
|
||||
|
||||
```http
|
||||
POST /api/v1/containers
|
||||
POST /api/v1/batch-create
|
||||
```
|
||||
|
||||
## 生命周期操作
|
||||
|
||||
```http
|
||||
POST /api/v1/containers/{id}/start
|
||||
POST /api/v1/containers/{id}/stop
|
||||
POST /api/v1/containers/{id}/restart
|
||||
POST /api/v1/containers/{id}/reinstall
|
||||
DELETE /api/v1/containers/{id}/delete
|
||||
```
|
||||
|
||||
开关机、重装、删除等操作会进入任务队列。调用后可通过 `GET /api/v1/tasks` 查看执行状态。
|
||||
|
||||
## 资源与流量
|
||||
|
||||
容器详情页支持查看资源用量,调整流量限制、资源限制和到期时间。
|
||||
|
||||
```http
|
||||
GET /api/v1/containers/{id}/usage
|
||||
GET /api/v1/containers/{id}/traffic
|
||||
POST /api/v1/containers/{id}/traffic-reset
|
||||
PUT /api/v1/containers/{id}/traffic-limit
|
||||
PUT /api/v1/containers/{id}/resource-limit
|
||||
PUT /api/v1/containers/{id}/expiry
|
||||
```
|
||||
|
||||
## NAT 端口管理
|
||||
|
||||
容器详情页的 NAT 端口管理支持新增、编辑和删除映射。新增和编辑会在弹窗里完成,便于集中填写名称、协议、外部端口和内部端口。
|
||||
|
||||
```http
|
||||
GET /api/v1/containers/{id}/random-port
|
||||
POST /api/v1/containers/{id}/port-mappings
|
||||
PUT /api/v1/containers/{id}/port-mappings/{index}
|
||||
DELETE /api/v1/containers/{id}/port-mappings/{index}
|
||||
```
|
||||
|
||||
子用户模式下,管理员可限制子用户只能调整内部端口,避免修改宿主机对外端口和协议。
|
||||
|
||||
## 远程控制台
|
||||
|
||||
```http
|
||||
POST /api/v1/ssh-ticket
|
||||
POST /api/v1/vnc-ticket
|
||||
```
|
||||
|
||||
票据只适合短时间使用,返回后应立即用于 WebSSH 或 WebVNC 连接,不要持久化保存。
|
||||
@@ -0,0 +1,27 @@
|
||||
# 控制面板
|
||||
|
||||
控制面板用于查看宿主机和虚拟化资源的整体状态。
|
||||
|
||||
## 统计项
|
||||
|
||||
- 容器总数、运行中数量和停止数量。
|
||||
- CPU、内存、磁盘、Swap 等资源概览。
|
||||
- 主机网络和路由状态入口。
|
||||
- 任务队列状态。
|
||||
- 安全告警摘要。
|
||||
|
||||
## 相关接口
|
||||
|
||||
```http
|
||||
GET /api/v1/dashboard
|
||||
GET /api/v1/host-info
|
||||
GET /api/v1/routing
|
||||
GET /api/v1/ipv6/status
|
||||
GET /api/v1/tasks
|
||||
```
|
||||
|
||||
API 需要携带 API Key:
|
||||
|
||||
```bash
|
||||
curl -H "X-API-Key: YOUR_API_KEY" https://panel.example.com/api/v1/dashboard
|
||||
```
|
||||
@@ -0,0 +1,21 @@
|
||||
# 主机报告
|
||||
|
||||
主机报告用于汇总宿主机运行环境、资源状态和虚拟化依赖,适合安装后自检、故障排查或给维护人员交付环境信息。
|
||||
|
||||
## 查看内容
|
||||
|
||||
- 系统版本和内核信息。
|
||||
- CPU、内存、磁盘、Swap。
|
||||
- 网络状态。
|
||||
- LXC/KVM 依赖状态。
|
||||
- CLICD 服务状态。
|
||||
|
||||
## 相关接口
|
||||
|
||||
```http
|
||||
GET /api/v1/host-report
|
||||
GET /api/v1/host-info
|
||||
GET /api/v1/swap
|
||||
```
|
||||
|
||||
对外发送报告前,请先检查是否包含公网 IP、内网网段、用户名、密钥、票据或业务域名。
|
||||
@@ -0,0 +1,29 @@
|
||||
# 镜像管理
|
||||
|
||||
镜像管理用于维护可创建容器或虚拟机的模板。
|
||||
|
||||
## 支持的模板类型
|
||||
|
||||
项目内置了常见 Linux 发行版模板,例如 Debian、Ubuntu、Alpine、CentOS、Fedora、Arch Linux、Rocky Linux 等。KVM 模板会使用对应发行版的云镜像资源。
|
||||
|
||||
## 管理动作
|
||||
|
||||
```http
|
||||
GET /api/v1/templates
|
||||
GET /api/v1/images
|
||||
POST /api/v1/images/download
|
||||
POST /api/v1/images/cancel
|
||||
DELETE /api/v1/images/delete
|
||||
PUT /api/v1/images/toggle
|
||||
```
|
||||
|
||||
- `templates` 返回可用模板定义。
|
||||
- `images` 返回本地镜像状态。
|
||||
- `download` 下载指定模板。
|
||||
- `cancel` 取消下载任务。
|
||||
- `delete` 删除本地镜像缓存。
|
||||
- `toggle` 控制模板是否对创建流程可用。
|
||||
|
||||
## Windows 镜像说明
|
||||
|
||||
本项目不分发 Windows 系统镜像,也不提供绕过或规避 Windows 激活机制的功能。涉及 Windows 的下载链接应指向微软官方资源,使用者需要自行获得合法授权。
|
||||
@@ -0,0 +1,39 @@
|
||||
# 网络与路由
|
||||
|
||||
CLICD 提供 NAT4 端口映射、随机可用端口、IPv6 状态检查和 IPv6 分配能力。
|
||||
|
||||
## NAT4
|
||||
|
||||
NAT4 用于把宿主机端口转发到容器内部端口。典型用途:
|
||||
|
||||
- 转发 SSH。
|
||||
- 暴露 Web 服务。
|
||||
- 给子用户分配固定外部端口。
|
||||
|
||||
端口映射包含:
|
||||
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| 名称 | 用于识别用途,例如 `ssh`、`web`。 |
|
||||
| 协议 | `tcp` 或 `udp`。 |
|
||||
| 外部端口 | 宿主机对外监听端口。 |
|
||||
| 内部端口 | 容器内部服务端口。 |
|
||||
|
||||
## IPv6
|
||||
|
||||
IPv6 分配要求宿主机本身拥有可路由 IPv6 地址段,并且系统路由、邻居发现或代理策略配置正确。
|
||||
|
||||
```http
|
||||
GET /api/v1/ipv6/status
|
||||
POST /api/v1/containers/{id}/ipv6
|
||||
```
|
||||
|
||||
如果宿主机没有公网 IPv6 或上游没有正确路由,面板中分配出的地址也无法从公网访问。
|
||||
|
||||
## 路由状态
|
||||
|
||||
```http
|
||||
GET /api/v1/routing
|
||||
```
|
||||
|
||||
该接口用于查看 NAT、IPv6、端口容量等运行时状态。
|
||||
@@ -0,0 +1,31 @@
|
||||
# 安全告警
|
||||
|
||||
CLICD 内置基于连接行为的轻量安全告警能力。它不保存完整正常连接日志,而是关注异常行为和高风险模式。
|
||||
|
||||
## 覆盖场景
|
||||
|
||||
- 端口扫描。
|
||||
- 横向扫描。
|
||||
- 爆破倾向。
|
||||
- SMTP 滥用。
|
||||
- UDP 反射风险。
|
||||
- 挖矿、代理、VPN、Tor 等可疑端口。
|
||||
|
||||
## 接口
|
||||
|
||||
```http
|
||||
GET /api/v1/security/alerts
|
||||
POST /api/v1/security/check
|
||||
GET /api/v1/security/logs?container={name}
|
||||
GET /api/v1/security/summary
|
||||
GET /api/v1/security/settings
|
||||
PUT /api/v1/security/settings
|
||||
```
|
||||
|
||||
## 自动关机
|
||||
|
||||
安全设置中可配置告警后的自动关机策略。开启前建议先观察一段时间,确认规则不会影响正常业务。
|
||||
|
||||
## 日志建议
|
||||
|
||||
安全告警适合做风险提示,不应替代专业防火墙、入侵检测或集中日志系统。对公网暴露服务时,仍建议结合安全组、防火墙、Fail2ban 等工具。
|
||||
@@ -0,0 +1,31 @@
|
||||
# 快照管理
|
||||
|
||||
快照用于保存容器当前状态,方便在升级、变更配置或交付前回滚。
|
||||
|
||||
## 全局总览
|
||||
|
||||
```http
|
||||
GET /api/v1/snapshots
|
||||
```
|
||||
|
||||
用于查看所有容器的快照概览。
|
||||
|
||||
## 容器快照
|
||||
|
||||
```http
|
||||
GET /api/v1/containers/{id}/snapshots
|
||||
POST /api/v1/containers/{id}/snapshots
|
||||
DELETE /api/v1/containers/{id}/snapshots/{snapshot_id}
|
||||
POST /api/v1/containers/{id}/snapshots/{snapshot_id}/restore
|
||||
```
|
||||
|
||||
恢复快照会改变容器状态,生产环境建议先确认当前业务是否可以中断。
|
||||
|
||||
## 计划快照与配额
|
||||
|
||||
```http
|
||||
POST /api/v1/containers/{id}/snapshots/schedule
|
||||
PUT /api/v1/containers/{id}/snapshots/quota
|
||||
```
|
||||
|
||||
计划快照适合长期运行的容器。配额用于避免快照无限增长占满宿主机磁盘。
|
||||
@@ -0,0 +1,28 @@
|
||||
# 子用户
|
||||
|
||||
子用户用于把指定容器授权给其他用户管理。它适合临时交付、拼车分配、教学实验或多人共用宿主机的场景。
|
||||
|
||||
## 创建访问链接
|
||||
|
||||
管理员选择容器后创建子用户链接:
|
||||
|
||||
```http
|
||||
POST /api/v1/sub-user/create
|
||||
```
|
||||
|
||||
返回内容中可能包含用户名、初始密码、访问码或访问链接。对外展示时必须脱敏,真实值只应发送给对应用户。
|
||||
|
||||
## 管理子用户
|
||||
|
||||
```http
|
||||
GET /api/v1/sub-users
|
||||
POST /api/v1/sub-users/{id}/rotate-password
|
||||
GET /api/v1/sub-users/{id}/audit-logs
|
||||
GET /api/v1/sub-users/{id}/login-logs
|
||||
```
|
||||
|
||||
轮换密码会让旧凭证失效。审计日志和登录日志可用于排查误操作或异常访问。
|
||||
|
||||
## 权限范围
|
||||
|
||||
子用户只能管理被授权的容器。涉及全局配置、镜像管理、安全策略、API Key 等管理员功能不会开放给子用户。
|
||||
@@ -0,0 +1,30 @@
|
||||
# 配置说明
|
||||
|
||||
CLICD 安装后会以 systemd 服务运行,运行时配置和数据库保存在宿主机本地。实际路径可能随安装脚本参数变化,默认安装建议以 `/root/.clicd/` 为主要检查位置。
|
||||
|
||||
## 常见配置项
|
||||
|
||||
| 配置 | 说明 |
|
||||
| --- | --- |
|
||||
| Web 端口 | 默认 `8999`,服务启动时监听 `0.0.0.0:8999`。 |
|
||||
| 管理员账号 | 用于登录 Web 面板和管理 API Key。 |
|
||||
| 数据库 | SQLite,用于保存容器元数据、子用户、审计日志、API Key 等。 |
|
||||
| NAT 端口范围 | 用于随机端口和端口映射分配。 |
|
||||
| IPv6 地址段 | 宿主机有可路由 IPv6 时可配置分配策略。 |
|
||||
| 安全告警 | 可配置自动关机等策略。 |
|
||||
|
||||
## 服务命令
|
||||
|
||||
```bash
|
||||
systemctl status clicd
|
||||
systemctl restart clicd
|
||||
journalctl -u clicd -n 100 --no-pager
|
||||
```
|
||||
|
||||
## 安全建议
|
||||
|
||||
- 不要把 Web 面板直接暴露给不可信来源。
|
||||
- 使用复杂管理员密码,并定期轮换。
|
||||
- API Key 按用途拆分权限,避免长期使用全权限密钥。
|
||||
- WebSSH、WebVNC 票据是短期凭证,不应写入日志或外发。
|
||||
- 对外文档、截图和工单里不要粘贴真实 IP、密码、API Key 或票据。
|
||||
@@ -0,0 +1,46 @@
|
||||
# 安装
|
||||
|
||||
CLICD 提供一键安装脚本。脚本默认安装 GitHub Releases 的最新版本,也可以通过环境变量指定固定版本。
|
||||
|
||||
## 环境要求
|
||||
|
||||
- Linux x86_64 宿主机。
|
||||
- root 权限。
|
||||
- systemd。
|
||||
- 网络可访问 GitHub Release 下载地址。
|
||||
- 如果要使用 LXC,需要宿主机支持 LXC 运行环境。
|
||||
- 如果要使用 KVM,需要宿主机开启虚拟化并安装 libvirt/QEMU。
|
||||
|
||||
## 安装最新版本
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/MengMengCode/CLICD/main/install.sh | sudo sh
|
||||
```
|
||||
|
||||
脚本当前默认使用 `CLICD_VERSION=latest`,也就是下载 `releases/latest` 对应的 `clicd-linux-amd64.tar.gz`。
|
||||
|
||||
## 安装指定版本
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/MengMengCode/CLICD/main/install.sh | sudo CLICD_VERSION=v1.1.6 sh
|
||||
```
|
||||
|
||||
把 `v1.1.6` 替换成需要安装的 Release 标签即可。
|
||||
|
||||
## 访问面板
|
||||
|
||||
安装完成后,浏览器访问:
|
||||
|
||||
```text
|
||||
http://YOUR_SERVER_IP:8999
|
||||
```
|
||||
|
||||
首次登录请使用安装脚本输出的管理员账号信息。生产环境建议在防火墙或反向代理层限制访问来源,并尽快修改默认账号和密码。
|
||||
|
||||
## 卸载
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/MengMengCode/CLICD/main/install.sh | sudo sh -s -- uninstall
|
||||
```
|
||||
|
||||
卸载前请确认是否需要保留容器、镜像缓存、数据库和配置文件。
|
||||
@@ -0,0 +1,29 @@
|
||||
# 项目介绍
|
||||
|
||||
CLICD 是一个面向 LXC/KVM 的轻量虚拟化管理面板。它把常见宿主机运维动作收敛到 Web 控制台和命令行里,适合用来管理小型 VPS、独立服务器或需要批量分发容器访问权限的场景。
|
||||
|
||||
## 核心能力
|
||||
|
||||
- 管理 LXC 容器和 KVM 虚拟机。
|
||||
- 创建、开机、关机、重启、重装、删除容器。
|
||||
- 配置 CPU、内存、磁盘、流量限制和到期时间。
|
||||
- 管理 NAT4 端口映射,并在宿主机具备 IPv6 路由时分配公网 IPv6。
|
||||
- 在浏览器中打开 WebSSH 或 WebVNC。
|
||||
- 管理镜像下载、启用状态和本地缓存。
|
||||
- 创建、恢复、删除快照,配置计划快照和快照配额。
|
||||
- 基于连接行为生成安全告警,并保留审计日志。
|
||||
- 为指定容器创建子用户访问链接。
|
||||
- 通过 API Key 接入 `/api/v1` 自动化接口。
|
||||
|
||||
## 适用场景
|
||||
|
||||
- 一台宿主机上需要快速分配多个 Linux 容器。
|
||||
- 需要给用户临时发放容器控制台、SSH、VNC 或 NAT 端口管理权限。
|
||||
- 希望用 API 自动化创建容器、调整资源、重置密码或回收资源。
|
||||
- 需要一个比纯 CLI 更直观,但又不重型的平台面板。
|
||||
|
||||
## 技术栈
|
||||
|
||||
- 后端:Go、`net/http`、SQLite、systemd、LXC、KVM/libvirt、cgroup v2、iptables、conntrack。
|
||||
- 前端:React、TypeScript、Vite、Tailwind CSS、lucide-react、xterm.js、noVNC。
|
||||
- 发布:GitHub Actions 构建 Linux AMD64 release 产物,安装脚本默认拉取最新 Release。
|
||||
@@ -0,0 +1,36 @@
|
||||
# 快速上手
|
||||
|
||||
下面是一条从安装后到创建第一台容器的常用路径。
|
||||
|
||||
## 1. 登录控制台
|
||||
|
||||
访问 `http://YOUR_SERVER_IP:8999`,使用管理员账号登录。
|
||||
|
||||
进入面板后先检查:
|
||||
|
||||
- 控制面板是否显示主机资源。
|
||||
- 镜像管理是否能列出模板。
|
||||
- 路由管理中 NAT 和 IPv6 状态是否符合宿主机预期。
|
||||
|
||||
## 2. 下载镜像
|
||||
|
||||
进入“镜像管理”,选择需要的模板并下载。宿主机资源较小时,可以优先选择 Alpine、Debian 这类轻量镜像。
|
||||
|
||||
镜像下载是异步任务,可以在任务队列中观察进度。
|
||||
|
||||
## 3. 创建容器
|
||||
|
||||
进入“容器管理”,点击创建:
|
||||
|
||||
- 选择虚拟化类型和模板。
|
||||
- 设置 CPU、内存、磁盘。
|
||||
- 设置流量限制和到期时间。
|
||||
- 如果需要外部访问,创建后到容器详情里添加 NAT 端口映射或分配 IPv6。
|
||||
|
||||
## 4. 打开终端
|
||||
|
||||
容器创建完成后,可以在详情页打开 WebSSH。KVM 虚拟机可使用 WebVNC 查看控制台。
|
||||
|
||||
## 5. 分发给子用户
|
||||
|
||||
如果需要把某个容器交给其他用户管理,进入“子用户管理”创建访问链接。子用户只会看到被授权的容器,并受到管理员配置的操作范围限制。
|
||||
@@ -0,0 +1,43 @@
|
||||
# 升级
|
||||
|
||||
CLICD 的安装脚本和 CLI 都围绕 GitHub Release 产物工作。升级前建议先确认当前版本、备份配置和数据库。
|
||||
|
||||
## 查看版本
|
||||
|
||||
Web 面板侧边栏底部会显示当前版本,也可以访问:
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:8999/api/version
|
||||
```
|
||||
|
||||
返回示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"version": "1.1.6"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 使用安装脚本升级
|
||||
|
||||
安装脚本默认使用最新 Release:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/MengMengCode/CLICD/main/install.sh | sudo sh
|
||||
```
|
||||
|
||||
指定版本:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/MengMengCode/CLICD/main/install.sh | sudo CLICD_VERSION=v1.1.6 sh
|
||||
```
|
||||
|
||||
## 升级前检查
|
||||
|
||||
- 确认 `/root/.clicd/` 或实际配置目录已备份。
|
||||
- 确认系统服务没有正在执行关键任务。
|
||||
- 如果正在下载镜像或恢复快照,建议等待任务完成后再升级。
|
||||
- 升级后检查 `systemctl status clicd` 和 Web 面板版本号。
|
||||
@@ -0,0 +1,23 @@
|
||||
---
|
||||
layout: home
|
||||
|
||||
hero:
|
||||
name: CLICD
|
||||
text: 轻量 LXC/KVM 虚拟化管理面板
|
||||
tagline: 提供 Web 控制台、CLI、容器编排、NAT/IPv6、快照、安全告警、子用户和 API 自动化能力。
|
||||
actions:
|
||||
- theme: brand
|
||||
text: 开始安装
|
||||
link: /guide/installation
|
||||
- theme: alt
|
||||
text: 查看 API
|
||||
link: /features/api
|
||||
|
||||
features:
|
||||
- title: 面向小型宿主机
|
||||
details: 适合在单台 VPS 或独立服务器上管理 LXC 容器与 KVM 虚拟机。
|
||||
- title: Web 与 CLI 并行
|
||||
details: 管理员可使用 Web 面板,也可以进入 clicd CLI 完成维护操作。
|
||||
- title: 自动化友好
|
||||
details: /api/v1 提供容器、镜像、快照、安全、日志、子用户和 API Key 管理接口。
|
||||
---
|
||||
@@ -0,0 +1,46 @@
|
||||
# 部署建议
|
||||
|
||||
CLICD 可以直接运行在宿主机上,也可以放在反向代理之后。生产环境建议先做好访问控制,再开放给管理员使用。
|
||||
|
||||
## 服务暴露
|
||||
|
||||
默认 Web 端口为 `8999`:
|
||||
|
||||
```text
|
||||
http://YOUR_SERVER_IP:8999
|
||||
```
|
||||
|
||||
建议:
|
||||
|
||||
- 仅允许固定管理员 IP 访问。
|
||||
- 使用反向代理配置 HTTPS。
|
||||
- 不要在公开文档或截图里暴露真实登录地址。
|
||||
|
||||
## systemd
|
||||
|
||||
常用命令:
|
||||
|
||||
```bash
|
||||
systemctl status clicd
|
||||
systemctl restart clicd
|
||||
systemctl enable clicd
|
||||
journalctl -u clicd -f
|
||||
```
|
||||
|
||||
## 防火墙
|
||||
|
||||
至少确认:
|
||||
|
||||
- 面板端口只对可信来源开放。
|
||||
- NAT 映射端口按需开放。
|
||||
- SSH 管理端口不与容器映射冲突。
|
||||
- IPv6 防火墙规则与 IPv4 同步规划。
|
||||
|
||||
## 备份
|
||||
|
||||
建议定期备份:
|
||||
|
||||
- CLICD 配置目录。
|
||||
- SQLite 数据库。
|
||||
- 容器配置。
|
||||
- 关键容器的快照或外部数据备份。
|
||||
@@ -0,0 +1,29 @@
|
||||
# 常见问题
|
||||
|
||||
## 安装脚本默认安装哪个版本?
|
||||
|
||||
默认安装 GitHub Releases 的最新版本。脚本中默认值是 `CLICD_VERSION=latest`,会下载 `releases/latest` 下的 Linux AMD64 产物。
|
||||
|
||||
## 可以固定安装某个版本吗?
|
||||
|
||||
可以:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/MengMengCode/CLICD/main/install.sh | sudo CLICD_VERSION=v1.1.6 sh
|
||||
```
|
||||
|
||||
## 子用户能看到全部容器吗?
|
||||
|
||||
不能。子用户只会看到管理员授权给他的容器。
|
||||
|
||||
## API Key 和登录密码一样吗?
|
||||
|
||||
不一样。API Key 在“API 集成”页面创建,用于程序化调用接口。登录密码用于 Web 面板登录。
|
||||
|
||||
## 到达流量限制后会怎样?
|
||||
|
||||
容器达到流量限制后会被自动关机,避免继续产生超额流量。管理员可以调整限制或重置流量。
|
||||
|
||||
## IPv6 分配后为什么公网不通?
|
||||
|
||||
IPv6 是否可达取决于宿主机和上游网络。需要确认宿主机拥有可路由 IPv6 地址段,并且路由、防火墙、邻居发现或代理配置正确。
|
||||
@@ -0,0 +1,46 @@
|
||||
# 故障排查
|
||||
|
||||
## 服务无法访问
|
||||
|
||||
检查服务状态:
|
||||
|
||||
```bash
|
||||
systemctl status clicd
|
||||
journalctl -u clicd -n 100 --no-pager
|
||||
```
|
||||
|
||||
检查端口监听:
|
||||
|
||||
```bash
|
||||
ss -lntp | grep 8999
|
||||
```
|
||||
|
||||
如果使用反向代理,请同时检查代理日志和上游地址。
|
||||
|
||||
## 镜像下载失败
|
||||
|
||||
- 确认宿主机可以访问镜像源和 GitHub Release。
|
||||
- 检查磁盘空间。
|
||||
- 在任务队列里查看失败原因。
|
||||
- 如下载卡住,可尝试取消任务后重新下载。
|
||||
|
||||
## 容器无法联网
|
||||
|
||||
- 检查宿主机 NAT 和转发规则。
|
||||
- 检查容器 IP 是否分配成功。
|
||||
- 检查防火墙是否拦截转发流量。
|
||||
- IPv6 场景下确认上游已经把地址段路由到宿主机。
|
||||
|
||||
## WebSSH 或 WebVNC 连接失败
|
||||
|
||||
- 确认容器或虚拟机正在运行。
|
||||
- WebSSH 需要容器内 SSH 服务可用。
|
||||
- WebVNC 需要 KVM 控制台可访问。
|
||||
- 票据有效期很短,过期后重新创建即可。
|
||||
|
||||
## API 返回未授权
|
||||
|
||||
- 确认 API Key 没有被禁用。
|
||||
- 确认请求头使用 `X-API-Key` 或 `Authorization: Bearer`。
|
||||
- 确认密钥权限范围覆盖目标接口。
|
||||
- 不要把面板登录密码当作 API Key 使用。
|
||||
Generated
+2511
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
cli.cd
|
||||
@@ -0,0 +1 @@
|
||||
<svg t="1780499553554" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="4260" width="200" height="200"><path d="M852.9 147.8c4.9 0 9.1 4.2 9.1 9.1v167.8c0 4.9-4.2 9.1-9.1 9.1H171.1c-4.9 0-9.1-4.2-9.1-9.1V156.9c0-4.9 4.2-9.1 9.1-9.1h681.8m0-50H171.1c-32.5 0-59.1 26.6-59.1 59.1v167.8c0 32.5 26.6 59.1 59.1 59.1h681.8c32.5 0 59.1-26.6 59.1-59.1V156.9c0-32.5-26.6-59.1-59.1-59.1z" p-id="4261" fill="#707070"></path><path d="M290.5 214h-60v60h60v-60zM393.5 214h-60v60h60v-60zM806 214H591v60h215v-60zM852.9 417.8c4.9 0 9.1 4.2 9.1 9.1v167.8c0 4.9-4.2 9.1-9.1 9.1H171.1c-4.9 0-9.1-4.2-9.1-9.1V426.9c0-4.9 4.2-9.1 9.1-9.1h681.8m0-50H171.1c-32.5 0-59.1 26.6-59.1 59.1v167.8c0 32.5 26.6 59.1 59.1 59.1h681.8c32.5 0 59.1-26.6 59.1-59.1V426.9c0-32.5-26.6-59.1-59.1-59.1z" p-id="4262" fill="#707070"></path><path d="M290.5 484h-60v60h60v-60zM393.5 484h-60v60h60v-60zM806 484H591v60h215v-60zM852.9 687.8c4.9 0 9.1 4.2 9.1 9.1v167.8c0 4.9-4.2 9.1-9.1 9.1H171.1c-4.9 0-9.1-4.2-9.1-9.1V696.9c0-4.9 4.2-9.1 9.1-9.1h681.8m0-50H171.1c-32.5 0-59.1 26.6-59.1 59.1v167.8c0 32.5 26.6 59.1 59.1 59.1h681.8c32.5 0 59.1-26.6 59.1-59.1V696.9c0-32.5-26.6-59.1-59.1-59.1z" p-id="4263" fill="#707070"></path><path d="M290.5 754h-60v60h60v-60zM393.5 754h-60v60h60v-60zM806 754H591v60h215v-60z" p-id="4264" fill="#707070"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "clicd-frontend",
|
||||
"private": true,
|
||||
"version": "1.1.5",
|
||||
"version": "1.1.7",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
ChevronUp,
|
||||
Copy,
|
||||
Edit3,
|
||||
Eye,
|
||||
Key,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
@@ -40,6 +41,20 @@ interface ApiKeyForm {
|
||||
}
|
||||
|
||||
const BASE_URL = window.location.origin
|
||||
const SAMPLE_BASE_URL = 'https://panel.example.com'
|
||||
|
||||
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
|
||||
type EndpointTuple = [HttpMethod, string, string]
|
||||
|
||||
interface EndpointDoc {
|
||||
method: HttpMethod
|
||||
path: string
|
||||
desc: string
|
||||
examplePath: string
|
||||
body?: Record<string, unknown>
|
||||
response: unknown
|
||||
note?: string
|
||||
}
|
||||
|
||||
const scopeGroups = [
|
||||
{
|
||||
@@ -116,7 +131,7 @@ const defaultReadScopes = [
|
||||
'host:read',
|
||||
]
|
||||
|
||||
const endpointGroups = [
|
||||
const endpointGroups: Array<{ title: string; endpoints: EndpointTuple[] }> = [
|
||||
{
|
||||
title: '总览',
|
||||
endpoints: [
|
||||
@@ -132,6 +147,7 @@ const endpointGroups = [
|
||||
title: '容器',
|
||||
endpoints: [
|
||||
['GET', '/api/v1/containers', '容器列表'],
|
||||
['POST', '/api/v1/containers/list', '容器列表(兼容旧接口)'],
|
||||
['POST', '/api/v1/containers', '创建容器'],
|
||||
['GET', '/api/v1/containers/{id|uuid|name}', '容器详情'],
|
||||
['POST', '/api/v1/containers/{id}/start', '开机'],
|
||||
@@ -226,6 +242,8 @@ export default function ApiIntegration() {
|
||||
const [newKey, setNewKey] = useState('')
|
||||
const [copiedKey, setCopiedKey] = useState(false)
|
||||
const [showDocs, setShowDocs] = useState(true)
|
||||
const [selectedEndpoint, setSelectedEndpoint] = useState<EndpointDoc | null>(null)
|
||||
const [copiedDoc, setCopiedDoc] = useState(false)
|
||||
|
||||
const containerNameByUUID = useMemo(() => {
|
||||
const map = new Map<string, string>()
|
||||
@@ -322,6 +340,15 @@ export default function ApiIntegration() {
|
||||
}
|
||||
}
|
||||
|
||||
const copyDocCode = async () => {
|
||||
if (!selectedEndpoint) return
|
||||
const copied = await copyToClipboard(buildPythonExample(selectedEndpoint))
|
||||
if (copied) {
|
||||
setCopiedDoc(true)
|
||||
setTimeout(() => setCopiedDoc(false), 1600)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleScope = (scope: string) => {
|
||||
setForm(prev => {
|
||||
if (scope === '*') {
|
||||
@@ -472,8 +499,9 @@ export default function ApiIntegration() {
|
||||
{showDocs && (
|
||||
<div className="space-y-6 p-5">
|
||||
<div className="rounded-lg bg-gray-900 p-4 font-mono text-xs text-gray-100">
|
||||
<div>curl -H "X-API-Key: clicd_sk_xxxx" {BASE_URL}/api/v1/containers</div>
|
||||
<div className="mt-2 text-gray-400">curl -H "Authorization: Bearer clicd_sk_xxxx" {BASE_URL}/api/v1/dashboard</div>
|
||||
<div>curl -X GET {BASE_URL}/api/v1/containers -H "X-API-Key: clicd_sk_xxxx"</div>
|
||||
<div className="mt-2 text-gray-400">curl -X GET {BASE_URL}/api/v1/dashboard -H "Authorization: Bearer clicd_sk_xxxx"</div>
|
||||
<div className="mt-2 text-amber-300">旧版 /api/containers/list 已兼容,但新接入请使用 GET /api/v1/containers</div>
|
||||
</div>
|
||||
|
||||
{endpointGroups.map(group => (
|
||||
@@ -481,10 +509,18 @@ export default function ApiIntegration() {
|
||||
<h3 className="mb-2 text-sm font-semibold text-black">{group.title}</h3>
|
||||
<div className="overflow-hidden rounded-lg border border-gray-200">
|
||||
{group.endpoints.map(([method, path, desc]) => (
|
||||
<div key={`${method}-${path}`} className="grid gap-2 border-b border-gray-100 px-3 py-2 text-xs last:border-b-0 md:grid-cols-[72px_minmax(280px,1fr)_180px]">
|
||||
<div key={`${method}-${path}`} className="grid gap-2 border-b border-gray-100 px-3 py-2 text-xs last:border-b-0 md:grid-cols-[72px_minmax(220px,1fr)_180px_72px]">
|
||||
<span className="w-fit rounded border border-blue-200 bg-blue-50 px-1.5 py-0.5 font-mono font-bold text-blue-700">{method}</span>
|
||||
<code className="min-w-0 break-all font-mono text-gray-800">{path}</code>
|
||||
<span className="text-gray-500">{desc}</span>
|
||||
<button
|
||||
onClick={() => setSelectedEndpoint(buildEndpointDoc(method, path, desc))}
|
||||
className="inline-flex w-fit items-center justify-center gap-1 rounded border border-gray-200 px-2 py-1 text-gray-600 hover:border-gray-300 hover:bg-gray-50 hover:text-black"
|
||||
title="查看使用范例"
|
||||
>
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
查看
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -494,6 +530,58 @@ export default function ApiIntegration() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedEndpoint && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-black/50" onClick={() => setSelectedEndpoint(null)} />
|
||||
<div className="relative flex max-h-[90vh] w-full max-w-5xl flex-col overflow-hidden rounded-lg border border-gray-200 bg-white shadow-xl">
|
||||
<div className="flex items-center justify-between gap-3 border-b border-gray-200 px-5 py-4">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="rounded border border-blue-200 bg-blue-50 px-1.5 py-0.5 font-mono text-xs font-bold text-blue-700">
|
||||
{selectedEndpoint.method}
|
||||
</span>
|
||||
<code className="min-w-0 break-all font-mono text-sm text-gray-900">{selectedEndpoint.path}</code>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-gray-500">{selectedEndpoint.desc}</p>
|
||||
</div>
|
||||
<button onClick={() => setSelectedEndpoint(null)} className="shrink-0 rounded p-1 text-gray-400 hover:text-black" title="关闭">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-5">
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<section className="min-w-0">
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<h3 className="text-sm font-semibold text-black">Python 使用范例</h3>
|
||||
<button
|
||||
onClick={copyDocCode}
|
||||
className="inline-flex items-center gap-1 rounded border border-gray-200 px-2 py-1 text-xs text-gray-600 hover:bg-gray-50"
|
||||
>
|
||||
{copiedDoc ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
|
||||
{copiedDoc ? '已复制' : '复制'}
|
||||
</button>
|
||||
</div>
|
||||
<pre className="max-h-[52vh] overflow-auto rounded-lg bg-gray-900 p-4 text-xs text-gray-100">
|
||||
<code>{buildPythonExample(selectedEndpoint)}</code>
|
||||
</pre>
|
||||
</section>
|
||||
|
||||
<section className="min-w-0">
|
||||
<h3 className="mb-2 text-sm font-semibold text-black">返回响应样例</h3>
|
||||
{selectedEndpoint.note && (
|
||||
<div className="mb-2 rounded border border-gray-200 bg-gray-50 px-3 py-2 text-xs text-gray-500">{selectedEndpoint.note}</div>
|
||||
)}
|
||||
<pre className="max-h-[52vh] overflow-auto rounded-lg bg-gray-900 p-4 text-xs text-gray-100">
|
||||
<code>{formatJSON(selectedEndpoint.response)}</code>
|
||||
</pre>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showForm && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-black/50" onClick={() => setShowForm(false)} />
|
||||
@@ -627,6 +715,383 @@ export default function ApiIntegration() {
|
||||
)
|
||||
}
|
||||
|
||||
const requestBodySamples: Record<string, Record<string, unknown>> = {
|
||||
'POST /api/v1/containers/list': {},
|
||||
'POST /api/v1/containers': {
|
||||
name: 'demo-lxc-01',
|
||||
virtualization: 'lxc',
|
||||
template_id: 'debian-bookworm',
|
||||
vcpu: 1,
|
||||
ram_mb: 512,
|
||||
disk_gb: 10,
|
||||
network_bw_mbps: 0,
|
||||
monthly_traffic_gb: 0,
|
||||
traffic_mode: 'total',
|
||||
traffic_in_gb: 0,
|
||||
traffic_out_gb: 0,
|
||||
io_speed_mbps: 0,
|
||||
extra_ports: [8080],
|
||||
port_mapping_count: 2,
|
||||
snapshot_limit: 1,
|
||||
assign_ipv6: true,
|
||||
expires_at: '',
|
||||
},
|
||||
'POST /api/v1/containers/{id}/reinstall': { template_id: 'debian-bookworm' },
|
||||
'PUT /api/v1/containers/{id}/traffic-limit': {
|
||||
traffic_mode: 'total',
|
||||
monthly_traffic_gb: 100,
|
||||
traffic_in_gb: 0,
|
||||
traffic_out_gb: 0,
|
||||
},
|
||||
'PUT /api/v1/containers/{id}/resource-limit': {
|
||||
vcpu: 1,
|
||||
ram_mb: 512,
|
||||
io_speed_mbps: 0,
|
||||
network_bw_mbps: 0,
|
||||
},
|
||||
'PUT /api/v1/containers/{id}/expiry': { expires_at: '2026-12-31 23:59:59' },
|
||||
'POST /api/v1/containers/{id}/reset-password': { password: 'NewPass123456' },
|
||||
'POST /api/v1/containers/{id}/port-mappings': {
|
||||
container_port: 8080,
|
||||
host_port: 61320,
|
||||
protocol: 'tcp',
|
||||
description: 'HTTP',
|
||||
},
|
||||
'PUT /api/v1/containers/{id}/port-mappings/{index}': {
|
||||
container_port: 8081,
|
||||
host_port: 61320,
|
||||
protocol: 'tcp',
|
||||
description: 'HTTP',
|
||||
},
|
||||
'POST /api/v1/containers/{id}/snapshots/schedule': {
|
||||
enabled: true,
|
||||
interval_hours: 24,
|
||||
time: '03:00',
|
||||
},
|
||||
'PUT /api/v1/containers/{id}/snapshots/quota': { snapshot_limit: 2 },
|
||||
'POST /api/v1/images/download': { template_id: 'debian-bookworm' },
|
||||
'POST /api/v1/images/cancel': { template_id: 'debian-bookworm' },
|
||||
'DELETE /api/v1/images/delete': { template_id: 'debian-bookworm' },
|
||||
'PUT /api/v1/images/toggle': { template_id: 'debian-bookworm', enabled: true },
|
||||
'POST /api/v1/security/check': { container_name: 'example-vm' },
|
||||
'PUT /api/v1/security/settings': { auto_shutdown: false },
|
||||
'POST /api/v1/swap': { action: 'resize', size_mb: 16384 },
|
||||
'POST /api/v1/batch-create': {
|
||||
containers: [
|
||||
{
|
||||
name: 'batch-lxc-01',
|
||||
virtualization: 'lxc',
|
||||
template_id: 'debian-bookworm',
|
||||
vcpu: 1,
|
||||
ram_mb: 512,
|
||||
disk_gb: 10,
|
||||
port_mapping_count: 2,
|
||||
snapshot_limit: 1,
|
||||
assign_ipv6: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
'POST /api/v1/batch-action': { action: 'restart', containers: [5], template_id: '' },
|
||||
'POST /api/v1/ssh-ticket': { container_name: 'example-vm' },
|
||||
'POST /api/v1/vnc-ticket': { container_name: 'kvm-demo' },
|
||||
'POST /api/v1/sub-user/create': { container_name: 'example-vm' },
|
||||
'POST /api/v1/sub-users/{id}/rotate-password': {},
|
||||
'POST /api/v1/api-keys': {
|
||||
name: 'Automation',
|
||||
ip_whitelist: '',
|
||||
scopes: ['dashboard:read', 'container:read'],
|
||||
expires_at: '',
|
||||
disabled: false,
|
||||
container_uuids: [],
|
||||
},
|
||||
'PATCH /api/v1/api-keys/{id}': {
|
||||
name: 'Automation',
|
||||
ip_whitelist: '',
|
||||
scopes: ['dashboard:read', 'container:read'],
|
||||
expires_at: '',
|
||||
disabled: false,
|
||||
container_uuids: [],
|
||||
},
|
||||
}
|
||||
|
||||
const responseSamples: Record<string, unknown> = {
|
||||
'GET /api/v1/dashboard': { success: true, data: { running: 31, stopped: 0, total_containers: 31 } },
|
||||
'GET /api/v1/host-info': {
|
||||
success: true,
|
||||
data: {
|
||||
cpu: { cores: 8, usage_pct: 1.16 },
|
||||
ram: { total_mb: 31825, used_mb: 1275, free_mb: 30550 },
|
||||
disk: { total_gb: 1750.49, used_gb: 123.98, free_gb: 1626.51 },
|
||||
network: {
|
||||
public_ipv4: '203.0.113.10',
|
||||
public_ipv4_interface: 'eth0',
|
||||
public_ipv6: '2001:db8:100::2',
|
||||
public_ipv6_interface: 'eth0',
|
||||
},
|
||||
load: { load1: 0.01, load5: 0.03, load15: 0.01 },
|
||||
},
|
||||
},
|
||||
'GET /api/v1/routing': {
|
||||
success: true,
|
||||
data: {
|
||||
nat4: { used: 62, remaining: '45474', total: '45536' },
|
||||
ipv6: { used: 31, remaining: 'large', total: 'large' },
|
||||
nat4_mappings: [
|
||||
{ container_id: 5, container_name: 'example-vm', status: 'running', ip: '10.0.0.10', host_port: 22004, container_port: 22, protocol: 'tcp' },
|
||||
],
|
||||
ipv6_assignments: [{ container_id: 5, container_name: 'example-vm', address: '2001:db8:100::1005', prefix_len: 64, interface: 'eth0' }],
|
||||
},
|
||||
},
|
||||
'GET /api/v1/ipv6/status': {
|
||||
success: true,
|
||||
data: {
|
||||
available: true,
|
||||
reachable: true,
|
||||
reason: 'usable public IPv6 prefix detected',
|
||||
prefixes: [{ interface: 'eth0', address: '2001:db8:100::2', prefix: '2001:db8:100::/64', prefix_len: 64, gateway: '2001:db8:100::1' }],
|
||||
},
|
||||
},
|
||||
'GET /api/v1/tasks': { success: true, data: [] },
|
||||
'DELETE /api/v1/tasks/{task_id}': { success: true, message: 'Task deleted' },
|
||||
'GET /api/v1/containers': {
|
||||
success: true,
|
||||
data: [
|
||||
{
|
||||
id: 5,
|
||||
uuid: '00000000-0000-4000-8000-000000000005',
|
||||
name: 'example-vm',
|
||||
virtualization: 'lxc',
|
||||
template: 'debian-bullseye',
|
||||
vcpu: 1,
|
||||
ram_mb: 512,
|
||||
disk_gb: 10,
|
||||
status: 'running',
|
||||
ip: '10.0.0.10',
|
||||
ipv6: '2001:db8:100::1005',
|
||||
ssh_port: 22004,
|
||||
ssh_password: '***',
|
||||
port_mappings: [
|
||||
{ container_port: 22, host_port: 22004, protocol: 'tcp', description: 'SSH' },
|
||||
{ container_port: 20000, host_port: 20000, protocol: 'tcp', description: 'Port-20000' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
'POST /api/v1/containers/list': {
|
||||
success: true,
|
||||
data: [
|
||||
{ id: 5, uuid: '00000000-0000-4000-8000-000000000005', name: 'example-vm', status: 'running', ip: '10.0.0.10' },
|
||||
],
|
||||
},
|
||||
'POST /api/v1/containers': { success: true, message: 'Container created successfully' },
|
||||
'GET /api/v1/containers/{id|uuid|name}': {
|
||||
success: true,
|
||||
data: {
|
||||
id: 5,
|
||||
uuid: '00000000-0000-4000-8000-000000000005',
|
||||
name: 'example-vm',
|
||||
status: 'running',
|
||||
ip: '10.0.0.10',
|
||||
ipv6: '2001:db8:100::1005',
|
||||
ssh_port: 22004,
|
||||
ssh_password: '***',
|
||||
policy_blocked: false,
|
||||
},
|
||||
},
|
||||
'POST /api/v1/containers/{id}/start': queuedTaskSample('start'),
|
||||
'POST /api/v1/containers/{id}/stop': queuedTaskSample('stop'),
|
||||
'POST /api/v1/containers/{id}/restart': queuedTaskSample('restart'),
|
||||
'POST /api/v1/containers/{id}/reinstall': queuedTaskSample('reinstall'),
|
||||
'DELETE /api/v1/containers/{id}/delete': queuedTaskSample('delete'),
|
||||
'GET /api/v1/containers/{id}/usage': {
|
||||
success: true,
|
||||
data: {
|
||||
cpu_usage_pct: 0,
|
||||
cpu_usage_usec: 3908852,
|
||||
memory_usage_bytes: 29331456,
|
||||
disk_usage_bytes: 515100672,
|
||||
network_rx_bytes: 131232,
|
||||
network_tx_bytes: 16828,
|
||||
load1: 0.1,
|
||||
load5: 0.06,
|
||||
load15: 0.01,
|
||||
},
|
||||
},
|
||||
'GET /api/v1/containers/{id}/traffic': {
|
||||
success: true,
|
||||
data: {
|
||||
mode: 'total',
|
||||
limit_gb: 0,
|
||||
in_limit_gb: 0,
|
||||
out_limit_gb: 0,
|
||||
total_used_bytes: 142082,
|
||||
rx_used_bytes: 127212,
|
||||
tx_used_bytes: 14870,
|
||||
used_pct: 0,
|
||||
reset_date: '2026-06',
|
||||
},
|
||||
},
|
||||
'POST /api/v1/containers/{id}/traffic-reset': { success: true, message: 'Traffic reset' },
|
||||
'PUT /api/v1/containers/{id}/traffic-limit': { success: true, message: 'Traffic limit updated' },
|
||||
'PUT /api/v1/containers/{id}/resource-limit': { success: true, message: 'Resource limits updated' },
|
||||
'PUT /api/v1/containers/{id}/expiry': { success: true, message: 'Expiry updated' },
|
||||
'POST /api/v1/containers/{id}/reset-password': { success: true, message: 'SSH password reset successfully', data: { password: '***' } },
|
||||
'POST /api/v1/containers/{id}/ipv6': { success: true, message: 'IPv6 assigned', data: { id: 5, name: 'example-vm', ipv6: '2001:db8:100::1005' } },
|
||||
'GET /api/v1/containers/{id}/random-port': { success: true, data: { port: 61320 } },
|
||||
'POST /api/v1/containers/{id}/port-mappings': {
|
||||
success: true,
|
||||
data: [
|
||||
{ container_port: 22, host_port: 22004, protocol: 'tcp', description: 'SSH' },
|
||||
{ container_port: 8080, host_port: 61320, protocol: 'tcp', description: 'HTTP' },
|
||||
],
|
||||
},
|
||||
'PUT /api/v1/containers/{id}/port-mappings/{index}': {
|
||||
success: true,
|
||||
data: [{ container_port: 8081, host_port: 61320, protocol: 'tcp', description: 'HTTP' }],
|
||||
},
|
||||
'DELETE /api/v1/containers/{id}/port-mappings/{index}': { success: true, data: [] },
|
||||
'GET /api/v1/snapshots': { success: true, data: null },
|
||||
'GET /api/v1/containers/{id}/snapshots': {
|
||||
success: true,
|
||||
data: { quota: 1, schedule: { enabled: false, interval_hours: 0, last_run: '', next_run: '', time: '', created_by: '' }, snapshots: [] },
|
||||
},
|
||||
'POST /api/v1/containers/{id}/snapshots': {
|
||||
success: true,
|
||||
data: { id: 'snap-20260608-001', container_id: 5, container_name: 'example-vm', created_at: '2026-06-08 16:00:00', created_by: 'api:Automation', scheduled: false, size_bytes: 10485760 },
|
||||
},
|
||||
'DELETE /api/v1/containers/{id}/snapshots/{snapshot_id}': { success: true, message: 'Snapshot deleted' },
|
||||
'POST /api/v1/containers/{id}/snapshots/{snapshot_id}/restore': { success: true, message: 'Snapshot restored' },
|
||||
'POST /api/v1/containers/{id}/snapshots/schedule': { success: true, data: { container: { id: 5, name: 'example-vm', snapshot_schedule_enabled: true, snapshot_schedule_interval_hours: 24, snapshot_schedule_time: '03:00' } } },
|
||||
'PUT /api/v1/containers/{id}/snapshots/quota': { success: true, data: { quota: 2, container: { id: 5, name: 'example-vm', snapshot_limit: 2 } } },
|
||||
'GET /api/v1/templates': {
|
||||
success: true,
|
||||
data: [
|
||||
{ id: 'ubuntu-noble', name: 'Ubuntu 24.04', distro: 'ubuntu', release: 'noble', arch: 'amd64', description: 'Ubuntu 24.04 LTS' },
|
||||
{ id: 'debian-bookworm', name: 'Debian 12', distro: 'debian', release: 'bookworm', arch: 'amd64', description: 'Debian 12 (Bookworm)' },
|
||||
],
|
||||
},
|
||||
'GET /api/v1/images': {
|
||||
success: true,
|
||||
data: [
|
||||
{ id: 'ubuntu-noble', name: 'Ubuntu 24.04', type: 'lxc', downloaded: true, enabled: true, downloading: false, progress: 0, size_bytes: 135005452 },
|
||||
],
|
||||
},
|
||||
'POST /api/v1/images/download': { success: true, message: 'Already downloaded' },
|
||||
'POST /api/v1/images/cancel': { success: true, message: 'Cancel requested' },
|
||||
'DELETE /api/v1/images/delete': { success: true, message: 'Deleted' },
|
||||
'PUT /api/v1/images/toggle': { success: true, message: 'OK' },
|
||||
'GET /api/v1/security/alerts': { success: true, data: [] },
|
||||
'POST /api/v1/security/check': { success: true, message: 'Security check completed' },
|
||||
'GET /api/v1/security/logs?container={name}': { success: true, data: [] },
|
||||
'GET /api/v1/security/summary': { success: true, data: { critical: 0, high: 0, low: 0, medium: 0, total_alerts: 0 } },
|
||||
'GET /api/v1/security/settings': { success: true, data: { auto_shutdown: false } },
|
||||
'PUT /api/v1/security/settings': { success: true, data: { auto_shutdown: false } },
|
||||
'GET /api/v1/swap': { success: true, data: { total_mb: 16383, used_mb: 0, free_mb: 16383, enabled: true, swap_file: '/swapfile' } },
|
||||
'POST /api/v1/swap': { success: true, message: 'SWAP 已调整为 16384 MB', data: { total_mb: 16383, used_mb: 0, free_mb: 16383, enabled: true, swap_file: '/swapfile' } },
|
||||
'POST /api/v1/batch-create': { success: true, data: ['task-12'] },
|
||||
'POST /api/v1/batch-action': { success: true, data: ['task-13'] },
|
||||
'POST /api/v1/ssh-ticket': { success: true, data: { ticket: '***60秒有效票据***' } },
|
||||
'POST /api/v1/vnc-ticket': { success: true, data: { ticket: '***60秒有效票据***' } },
|
||||
'POST /api/v1/sub-user/create': {
|
||||
success: true,
|
||||
message: 'Sub-user created',
|
||||
data: { id: 'sub-xxxxxxxx', username: 'user-xxxxxxxx', password: '***', container_names: ['example-vm'], access_code: '********', created_at: '2026-06-08 16:00:00' },
|
||||
},
|
||||
'GET /api/v1/sub-users': { success: true, data: [] },
|
||||
'POST /api/v1/sub-users/{id}/rotate-password': { success: true, data: { username: 'user-xxxxxxxx', password: '***', access_code: '********' } },
|
||||
'GET /api/v1/sub-users/{id}/audit-logs': { success: true, data: [] },
|
||||
'GET /api/v1/sub-users/{id}/login-logs': { success: true, data: [] },
|
||||
'GET /api/v1/audit-logs': {
|
||||
success: true,
|
||||
data: [{ time: '2026-06-08 15:44:40', action: 'apikey.create', target: 'Test', detail: 'scopes=*', user: 'admin', success: true }],
|
||||
},
|
||||
'GET /api/v1/login-logs': {
|
||||
success: true,
|
||||
data: [{ time: '2026-06-08 08:24:00 UTC', username: 'admin', ip: '198.51.100.23', user_agent: 'Mozilla/5.0 ...', success: true }],
|
||||
},
|
||||
'GET /api/v1/api-keys': {
|
||||
success: true,
|
||||
data: [{ id: 'c271023f', name: 'Test', prefix: 'clicd_sk_dd9d...', ip_whitelist: '', created_at: '2026-06-08 15:44:40', last_used: '2026-06-08 15:46:10', scopes: ['*'], last_used_ip: '198.51.100.23' }],
|
||||
},
|
||||
'POST /api/v1/api-keys': {
|
||||
success: true,
|
||||
message: "API key created. Save this key now - it won't be shown again.",
|
||||
data: { id: 'a1b2c3d4', name: 'Automation', key: 'clicd_sk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', prefix: 'clicd_sk_xxxx...', scopes: ['dashboard:read', 'container:read'] },
|
||||
},
|
||||
'PATCH /api/v1/api-keys/{id}': { success: true, data: { id: 'a1b2c3d4', name: 'Automation', prefix: 'clicd_sk_xxxx...', scopes: ['dashboard:read', 'container:read'], disabled: false } },
|
||||
'DELETE /api/v1/api-keys/{id}': { success: true, message: 'API key deleted' },
|
||||
}
|
||||
|
||||
function queuedTaskSample(action: string) {
|
||||
return {
|
||||
success: true,
|
||||
message: 'Task queued',
|
||||
data: { task_id: 'task-10', container_name: 'example-vm', status: 'pending', action },
|
||||
}
|
||||
}
|
||||
|
||||
function buildEndpointDoc(method: HttpMethod, path: string, desc: string): EndpointDoc {
|
||||
const key = `${method} ${path}`
|
||||
return {
|
||||
method,
|
||||
path,
|
||||
desc,
|
||||
examplePath: examplePathFor(path),
|
||||
body: requestBodySamples[key],
|
||||
response: responseSamples[key] || defaultResponseFor(method),
|
||||
note: endpointNoteFor(key),
|
||||
}
|
||||
}
|
||||
|
||||
function examplePathFor(path: string) {
|
||||
return path
|
||||
.replace('{id|uuid|name}', '5')
|
||||
.replace('{id}', '5')
|
||||
.replace('{task_id}', 'task-10')
|
||||
.replace('{index}', '1')
|
||||
.replace('{snapshot_id}', 'snap-20260608-001')
|
||||
.replace('{name}', 'example-vm')
|
||||
}
|
||||
|
||||
function endpointNoteFor(key: string) {
|
||||
if (key.includes('/vnc-ticket')) return 'WebVNC 仅适用于 KVM 虚拟机;LXC 容器会返回 VNC console is only available for KVM VMs。'
|
||||
if (key.includes('/containers/{id}/delete') || key.includes('/batch-action')) return '该接口会进入任务队列,请随后调用 GET /api/v1/tasks 查看执行状态。'
|
||||
if (key.includes('/reset-password') || key.includes('/api-keys') || key.includes('/sub-user')) return '样例中的密钥、密码和票据已脱敏;创建类接口的完整密钥只在创建响应中出现一次。'
|
||||
return ''
|
||||
}
|
||||
|
||||
function defaultResponseFor(method: HttpMethod) {
|
||||
if (method === 'GET') return { success: true, data: [] }
|
||||
if (method === 'DELETE') return { success: true, message: 'Deleted' }
|
||||
return { success: true, message: 'OK' }
|
||||
}
|
||||
|
||||
function buildPythonExample(doc: EndpointDoc) {
|
||||
const hasBody = doc.body !== undefined && doc.method !== 'GET'
|
||||
const bodyJSON = hasBody ? formatJSON(doc.body) : ''
|
||||
return [
|
||||
'import json',
|
||||
'import requests',
|
||||
'',
|
||||
`BASE_URL = "${SAMPLE_BASE_URL}"`,
|
||||
'API_KEY = "clicd_sk_xxxx"',
|
||||
'',
|
||||
...(hasBody ? [`payload = json.loads(r'''${bodyJSON}''')`, ''] : []),
|
||||
`response = requests.${doc.method.toLowerCase()}(`,
|
||||
` f"{BASE_URL}${doc.examplePath}",`,
|
||||
' headers={"X-API-Key": API_KEY},',
|
||||
...(hasBody ? [' json=payload,'] : []),
|
||||
' timeout=30,',
|
||||
')',
|
||||
'response.raise_for_status()',
|
||||
'print(response.json())',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function formatJSON(value: unknown) {
|
||||
return JSON.stringify(value, null, 2)
|
||||
}
|
||||
|
||||
function ScopeSummary({ scopes }: { scopes: string[] }) {
|
||||
if (scopes.includes('*')) {
|
||||
return <span className="rounded bg-red-50 px-2 py-1 text-xs font-medium text-red-600">全权限</span>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -106,7 +106,7 @@ export default function Login() {
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-xs text-gray-400 mt-6">CLICD v1.1.5</p>
|
||||
<p className="text-center text-xs text-gray-400 mt-6">CLICD v1.1.7</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
+304
-82
@@ -1,10 +1,13 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Clock, Globe, LogIn, Monitor, UserCog } 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'
|
||||
@@ -21,6 +24,16 @@ export default function Settings() {
|
||||
const [newPwd, setNewPwd] = useState('')
|
||||
const [newUsername, setNewUsername] = 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 {
|
||||
const res = await getLoginLogs()
|
||||
@@ -32,11 +45,61 @@ export default function Settings() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
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])
|
||||
}, [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) {
|
||||
@@ -92,93 +155,252 @@ export default function Settings() {
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-black">面板设置</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">账号管理与登录日志</p>
|
||||
<p className="mt-1 text-sm text-gray-500">账号、安全证书与登录日志</p>
|
||||
</div>
|
||||
|
||||
<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 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="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>
|
||||
|
||||
<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" />登录日志
|
||||
<LoginLogCard logs={logs} logPage={logPage} pageSize={pageSize} totalPages={totalPages} setLogPage={setLogPage} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface SSLCardProps {
|
||||
ssl: SSLSettings | null
|
||||
sslEnabled: boolean
|
||||
sslMode: SSLSettings['mode']
|
||||
sslTarget: string
|
||||
sslEmail: string
|
||||
certPEM: string
|
||||
keyPEM: string
|
||||
applyNow: boolean
|
||||
savingSSL: boolean
|
||||
onRefresh: () => void
|
||||
onEnabledChange: (enabled: boolean) => void
|
||||
onModeChange: (mode: SSLSettings['mode']) => void
|
||||
onTargetChange: (target: string) => void
|
||||
onEmailChange: (email: string) => void
|
||||
onCertChange: (cert: string) => void
|
||||
onKeyChange: (key: string) => void
|
||||
onApplyNowChange: (apply: boolean) => void
|
||||
onSave: () => void
|
||||
}
|
||||
|
||||
function SSLCard(props: SSLCardProps) {
|
||||
const selectedSSL = props.ssl?.mode_certificates?.[props.sslMode]
|
||||
const modeOptions: Array<{ value: SSLSettings['mode']; label: string }> = [
|
||||
{ value: 'letsencrypt', label: 'Let’s Encrypt' },
|
||||
{ value: 'self_signed', label: '自签证书' },
|
||||
{ value: 'uploaded', label: '上传证书' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-5">
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<h2 className="flex items-center gap-2 text-sm font-semibold text-black">
|
||||
<ShieldCheck className="h-4 w-4" />SSL 证书
|
||||
</h2>
|
||||
{logs.length === 0 ? (
|
||||
<p className="text-sm text-gray-400">暂无登录记录</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="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>
|
||||
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
|
||||
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={`h-7 w-7 rounded text-xs ${page === logPage ? 'bg-black text-white' : 'border border-gray-200 hover:bg-gray-50'}`}>{page}</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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{props.sslMode === 'letsencrypt' && (
|
||||
<div className="rounded-md border border-amber-200 bg-amber-50 p-3 text-xs text-amber-800">
|
||||
纯 IP 证书需要服务器安装 Certbot 5.4+,且验证时 80 端口必须能被 Let’s Encrypt 访问。IP 证书是短有效期证书,certbot 需要保持自动续签。
|
||||
</div>
|
||||
)}
|
||||
|
||||
{props.sslMode === 'self_signed' && (
|
||||
<div className="rounded-md border border-gray-100 bg-gray-50 p-3 text-xs text-gray-600">
|
||||
自签证书可以加密面板和 VNC,但浏览器会提示证书不受信任;证书快到期时系统会自动重新签发。
|
||||
</div>
|
||||
)}
|
||||
|
||||
{props.sslMode === 'uploaded' && (
|
||||
<div className="grid gap-3 lg:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-gray-500">证书 PEM / fullchain.pem</label>
|
||||
<textarea value={props.certPEM} onChange={(e) => props.onCertChange(e.target.value)} rows={7} className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 font-mono text-xs text-black" placeholder="-----BEGIN CERTIFICATE-----" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-gray-500">私钥 PEM / privkey.pem</label>
|
||||
<textarea value={props.keyPEM} onChange={(e) => props.onKeyChange(e.target.value)} rows={7} className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 font-mono text-xs text-black" placeholder="-----BEGIN PRIVATE KEY-----" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedSSL?.certificate ? (
|
||||
<div className="rounded-md border border-gray-100 bg-gray-50 p-3 text-xs text-gray-600">
|
||||
<div className="flex items-center gap-2 text-gray-800">
|
||||
<Lock className="h-3.5 w-3.5" />
|
||||
当前证书:{selectedSSL.certificate.valid ? '有效' : '已过期或未生效'}
|
||||
</div>
|
||||
<div className="mt-1 font-mono">到期时间:{selectedSSL.certificate.not_after}</div>
|
||||
<div className="mt-1 truncate font-mono" title={selectedSSL.cert_path}>证书路径:{selectedSSL.cert_path || '-'}</div>
|
||||
{selectedSSL.last_error && <div className="mt-1 text-red-600">最近错误:{selectedSSL.last_error}</div>}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border border-gray-100 bg-gray-50 p-3 text-xs text-gray-600">
|
||||
{props.sslMode === 'uploaded' ? '上传来源还没有保存证书,请粘贴证书和私钥后保存。' : '当前来源还没有保存证书,保存 SSL 设置时会自动生成或申请。'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label className="flex items-center gap-2 text-xs text-gray-500">
|
||||
<input type="checkbox" checked={props.applyNow} onChange={(e) => props.onApplyNowChange(e.target.checked)} className="h-4 w-4 rounded border-gray-300" />
|
||||
保存后自动重启服务并立即生效
|
||||
</label>
|
||||
|
||||
<button onClick={props.onSave} disabled={props.savingSSL} className="inline-flex w-full items-center justify-center gap-2 rounded-md bg-black px-4 py-2 text-sm text-white hover:bg-gray-800 disabled:opacity-50">
|
||||
<Upload className="h-4 w-4" />
|
||||
{props.savingSSL ? '保存中...' : '保存 SSL 设置'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface LoginLogCardProps {
|
||||
logs: LoginLog[]
|
||||
logPage: number
|
||||
pageSize: number
|
||||
totalPages: number
|
||||
setLogPage: Dispatch<SetStateAction<number>>
|
||||
}
|
||||
|
||||
function LoginLogCard({ logs, logPage, pageSize, totalPages, setLogPage }: LoginLogCardProps) {
|
||||
return (
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-5">
|
||||
<h2 className="mb-4 flex items-center gap-2 text-sm font-semibold text-black">
|
||||
<LogIn className="h-4 w-4" />登录日志
|
||||
</h2>
|
||||
{logs.length === 0 ? (
|
||||
<p className="text-sm text-gray-400">暂无登录记录</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-100 text-gray-400">
|
||||
<th className="w-40 py-2 text-left font-medium"><span className="inline-flex items-center gap-1"><Clock className="h-3 w-3" />时间</span></th>
|
||||
<th className="py-2 text-left font-medium">用户名</th>
|
||||
<th className="py-2 text-left font-medium"><span className="inline-flex items-center gap-1"><Globe className="h-3 w-3" />IP</span></th>
|
||||
<th className="py-2 text-left font-medium"><span className="inline-flex items-center gap-1"><Monitor className="h-3 w-3" />设备</span></th>
|
||||
<th className="py-2 text-left font-medium">结果</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-50">
|
||||
{logs.slice((logPage - 1) * pageSize, logPage * pageSize).map((log, index) => (
|
||||
<tr key={`${log.time}-${index}`}>
|
||||
<td className="whitespace-nowrap py-1.5 font-mono text-gray-500">{log.time}</td>
|
||||
<td className="py-1.5 text-gray-700">{log.username}</td>
|
||||
<td className="py-1.5 font-mono text-gray-500">{log.ip}</td>
|
||||
<td className="max-w-[180px] truncate py-1.5 text-gray-500" title={log.user_agent}>{formatUA(log.user_agent)}</td>
|
||||
<td className="py-1.5">
|
||||
<span className={`rounded px-1.5 py-0.5 text-xs ${log.success ? 'bg-gray-100 text-gray-700' : 'bg-red-50 text-red-600'}`}>
|
||||
{log.success ? '成功' : '失败'}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{logs.length > pageSize && (
|
||||
<div className="mt-3 flex items-center justify-between border-t border-gray-100 pt-3">
|
||||
<span className="text-xs text-gray-400">共 {logs.length} 条,第 {logPage}/{totalPages} 页</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<button onClick={() => setLogPage(1)} disabled={logPage === 1} className="rounded border border-gray-200 px-2 py-1 text-xs hover:bg-gray-50 disabled:opacity-30">首页</button>
|
||||
<button onClick={() => setLogPage(p => Math.max(1, p - 1))} disabled={logPage === 1} className="rounded border border-gray-200 px-2 py-1 text-xs hover:bg-gray-50 disabled:opacity-30">上一页</button>
|
||||
<button onClick={() => setLogPage(p => Math.min(totalPages, p + 1))} disabled={logPage >= totalPages} className="rounded border border-gray-200 px-2 py-1 text-xs hover:bg-gray-50 disabled:opacity-30">下一页</button>
|
||||
<button onClick={() => setLogPage(totalPages)} disabled={logPage >= totalPages} className="rounded border border-gray-200 px-2 py-1 text-xs hover:bg-gray-50 disabled:opacity-30">末页</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -317,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')
|
||||
|
||||
Reference in New Issue
Block a user