mirror of
https://github.com/MengMengCode/CLICD.git
synced 2026-08-05 05:36:07 +08:00
Compare commits
44 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e66327db29 | |||
| 2b4fe4f5bc | |||
| a923daa7a2 | |||
| c46f84c66e | |||
| 835bb51c6e | |||
| 7aed51e86b | |||
| e364807fb9 | |||
| c63ce02709 | |||
| 54f9ed7f7d | |||
| b01f9fe301 | |||
| d03e2c4c0c | |||
| 33603f5776 | |||
| 9ad7bcc97a | |||
| f3a1687a18 | |||
| 49b13af91c | |||
| e79609281f | |||
| 2fa130a2b6 | |||
| 14d2192b05 | |||
| 9f5ad94a83 | |||
| ac6587f2bc | |||
| 6fad37b844 | |||
| d0eb92eaab | |||
| 5207082cd1 | |||
| 608b50f18a | |||
| b58a6b1030 | |||
| 366f889a8c | |||
| 814441e9a0 | |||
| aed11af105 | |||
| 3d95bb33c1 | |||
| ade1c6c093 | |||
| 5c4cc1cab3 | |||
| 109e47170f | |||
| 34637cc79d | |||
| 7d48889eea | |||
| 2bcdb9e095 | |||
| 2ab42e7f57 | |||
| 3257cbb2a3 | |||
| 1ff5d7a85e | |||
| a99781d418 | |||
| b993e57d05 | |||
| 7ae0c91813 | |||
| aab58aca6e | |||
| 0b27604f95 | |||
| 460614e274 |
Submodule .claude/worktrees/agent-ae3871aebda20eb86 deleted from 422e48b524
@@ -0,0 +1,65 @@
|
||||
name: Deploy Docs
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
paths:
|
||||
- "docs/**"
|
||||
- ".github/workflows/pages.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: github-pages
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build VitePress
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: npm
|
||||
cache-dependency-path: docs/package-lock.json
|
||||
|
||||
- name: Setup Pages
|
||||
uses: actions/configure-pages@v5
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: docs
|
||||
run: npm ci
|
||||
|
||||
- name: Build docs
|
||||
working-directory: docs
|
||||
env:
|
||||
VITEPRESS_BASE: /
|
||||
run: npm run build
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: docs/.vitepress/dist
|
||||
|
||||
deploy:
|
||||
name: Deploy GitHub Pages
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
@@ -58,6 +58,7 @@ backend/tmp/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.claude/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
+5
-5
@@ -1,18 +1,18 @@
|
||||
module clicd
|
||||
|
||||
go 1.23.0
|
||||
go 1.24.0
|
||||
|
||||
toolchain go1.24.5
|
||||
|
||||
require (
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
golang.org/x/crypto v0.28.0
|
||||
golang.org/x/term v0.28.0
|
||||
golang.org/x/crypto v0.45.0
|
||||
golang.org/x/term v0.37.0
|
||||
)
|
||||
|
||||
require (
|
||||
golang.org/x/sys v0.31.0
|
||||
golang.org/x/sys v0.38.0
|
||||
modernc.org/sqlite v1.29.10
|
||||
)
|
||||
|
||||
|
||||
+8
-8
@@ -1,7 +1,7 @@
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
|
||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
@@ -18,8 +18,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
|
||||
golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=
|
||||
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
||||
golang.org/x/exp v0.0.0-20231108232855-2478ac86f678 h1:mchzmB1XO2pMaKFRqk/+MV3mgGG96aqaPXaMifQU47w=
|
||||
golang.org/x/exp v0.0.0-20231108232855-2478ac86f678/go.mod h1:zk2irFbV9DP96SEBUUAy67IdHUaZuSnrz1n472HUCLE=
|
||||
golang.org/x/mod v0.19.0 h1:fEdghXQSo20giMthA7cd28ZC+jts4amQ3YMXiP5oMQ8=
|
||||
@@ -27,10 +27,10 @@ golang.org/x/mod v0.19.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
|
||||
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg=
|
||||
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
|
||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
|
||||
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
|
||||
golang.org/x/tools v0.23.0 h1:SGsXPZ+2l4JsgaCKkx+FQ9YZ5XEtA1GZYuoDjenLjvg=
|
||||
golang.org/x/tools v0.23.0/go.mod h1:pnu6ufv6vQkll6szChhK3C3L/ruaIv5eBeztNG8wtsI=
|
||||
modernc.org/cc/v4 v4.24.4 h1:TFkx1s6dCkQpd6dKurBNmpo+G8Zl4Sq/ztJ+2+DEsh0=
|
||||
|
||||
+363
-124
@@ -2,122 +2,293 @@ package api
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"clicd/internal/config"
|
||||
|
||||
"golang.org/x/crypto/argon2"
|
||||
)
|
||||
|
||||
type ApiKey struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Key string `json:"key,omitempty"`
|
||||
Prefix string `json:"prefix"`
|
||||
IPWhitelist string `json:"ip_whitelist"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
LastUsed string `json:"last_used"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Key string `json:"key,omitempty"`
|
||||
Prefix string `json:"prefix"`
|
||||
IPWhitelist string `json:"ip_whitelist"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
LastUsed string `json:"last_used"`
|
||||
Scopes []string `json:"scopes,omitempty"`
|
||||
ExpiresAt string `json:"expires_at,omitempty"`
|
||||
Disabled bool `json:"disabled,omitempty"`
|
||||
ContainerUUIDs []string `json:"container_uuids,omitempty"`
|
||||
LastUsedIP string `json:"last_used_ip,omitempty"`
|
||||
}
|
||||
|
||||
type apiKeyRequest struct {
|
||||
Name string `json:"name"`
|
||||
IPWhitelist string `json:"ip_whitelist"`
|
||||
Scopes []string `json:"scopes"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
Disabled bool `json:"disabled"`
|
||||
ContainerUUIDs []string `json:"container_uuids"`
|
||||
}
|
||||
|
||||
var defaultApiKeyScopes = []string{
|
||||
"dashboard:read",
|
||||
"container:read",
|
||||
"task:read",
|
||||
"image:read",
|
||||
"snapshot:read",
|
||||
"routing:read",
|
||||
"ipv6:read",
|
||||
"host:read",
|
||||
}
|
||||
|
||||
// HandleApiKeys handles GET (list) and POST (create) for API keys
|
||||
func HandleApiKeys(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
if !requireScope(w, r, "apikey:read") {
|
||||
return
|
||||
}
|
||||
listApiKeys(w, r)
|
||||
case http.MethodPost:
|
||||
if !requireScope(w, r, "apikey:create") {
|
||||
return
|
||||
}
|
||||
createApiKey(w, r)
|
||||
default:
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||
}
|
||||
}
|
||||
|
||||
// HandleApiKeyDelete handles DELETE for a specific API key
|
||||
// HandleApiKeyDelete handles PATCH and DELETE for a specific API key
|
||||
func HandleApiKeyDelete(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodDelete {
|
||||
switch r.Method {
|
||||
case http.MethodPatch:
|
||||
if !requireScope(w, r, "apikey:update") {
|
||||
return
|
||||
}
|
||||
updateApiKey(w, r)
|
||||
case http.MethodDelete:
|
||||
if !requireScope(w, r, "apikey:delete") {
|
||||
return
|
||||
}
|
||||
deleteApiKey(w, r)
|
||||
default:
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||
return
|
||||
}
|
||||
keyID := strings.TrimPrefix(r.URL.Path, "/api/api-keys/")
|
||||
if keyID == "" {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Key ID required"})
|
||||
return
|
||||
}
|
||||
config.DeleteApiKey(keyID)
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "API key deleted"})
|
||||
}
|
||||
|
||||
func apiKeyIDFromPath(path string) string {
|
||||
path = strings.TrimPrefix(path, "/api/api-keys/")
|
||||
path = strings.TrimPrefix(path, "/api/v1/api-keys/")
|
||||
return strings.Trim(path, "/")
|
||||
}
|
||||
|
||||
func listApiKeys(w http.ResponseWriter, r *http.Request) {
|
||||
keys := make([]ApiKey, 0)
|
||||
for _, k := range config.AppConfig.ApiKeys {
|
||||
keys = append(keys, ApiKey{
|
||||
ID: k.ID,
|
||||
Name: k.Name,
|
||||
Prefix: k.Prefix,
|
||||
IPWhitelist: k.IPWhitelist,
|
||||
CreatedAt: k.CreatedAt,
|
||||
LastUsed: k.LastUsed,
|
||||
})
|
||||
keys = append(keys, apiKeyResponse(k))
|
||||
}
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: keys})
|
||||
}
|
||||
|
||||
func createApiKey(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
IPWhitelist string `json:"ip_whitelist"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Name == "" {
|
||||
var req apiKeyRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.Name) == "" {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Name is required"})
|
||||
return
|
||||
}
|
||||
if req.ExpiresAt != "" && !validApiKeyTime(req.ExpiresAt) {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid expiration date"})
|
||||
return
|
||||
}
|
||||
|
||||
// Generate key: clicd_sk_ + 32 hex chars
|
||||
rawBytes := make([]byte, 16)
|
||||
rand.Read(rawBytes)
|
||||
if _, err := rand.Read(rawBytes); err != nil {
|
||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to generate API key"})
|
||||
return
|
||||
}
|
||||
rawKey := "clicd_sk_" + hex.EncodeToString(rawBytes)
|
||||
|
||||
keyHash, err := hashAPIKey(rawKey)
|
||||
if err != nil {
|
||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to store API key"})
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().Format("2006-01-02 15:04:05")
|
||||
scopes := normalizeRequestedScopes(req.Scopes, defaultApiKeyScopes)
|
||||
key := config.ApiKeyConfig{
|
||||
ID: generateShortID(),
|
||||
Name: req.Name,
|
||||
KeyHash: hashKey(rawKey),
|
||||
Prefix: rawKey[:13] + "...",
|
||||
IPWhitelist: strings.TrimSpace(req.IPWhitelist),
|
||||
CreatedAt: now,
|
||||
ID: generateShortID(),
|
||||
Name: strings.TrimSpace(req.Name),
|
||||
KeyHash: keyHash,
|
||||
Prefix: rawKey[:13] + "...",
|
||||
IPWhitelist: strings.TrimSpace(req.IPWhitelist),
|
||||
CreatedAt: now,
|
||||
Scopes: scopes,
|
||||
ExpiresAt: strings.TrimSpace(req.ExpiresAt),
|
||||
Disabled: req.Disabled,
|
||||
ContainerUUIDs: normalizeStringSlice(req.ContainerUUIDs),
|
||||
}
|
||||
config.AppConfig.ApiKeys = append(config.AppConfig.ApiKeys, key)
|
||||
config.SaveConfig()
|
||||
if err := config.SaveConfig(); err != nil {
|
||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to save API key"})
|
||||
return
|
||||
}
|
||||
auditRequest(r, "apikey.create", key.Name, "scopes="+strings.Join(key.Scopes, ","), true, "")
|
||||
|
||||
resp := apiKeyResponse(key)
|
||||
resp.Key = rawKey
|
||||
jsonResponse(w, http.StatusCreated, APIResponse{
|
||||
Success: true,
|
||||
Message: "API key created. Save this key now - it won't be shown again.",
|
||||
Data: ApiKey{
|
||||
ID: key.ID,
|
||||
Name: key.Name,
|
||||
Key: rawKey,
|
||||
Prefix: key.Prefix,
|
||||
IPWhitelist: key.IPWhitelist,
|
||||
CreatedAt: key.CreatedAt,
|
||||
},
|
||||
Data: resp,
|
||||
})
|
||||
}
|
||||
|
||||
func updateApiKey(w http.ResponseWriter, r *http.Request) {
|
||||
keyID := apiKeyIDFromPath(r.URL.Path)
|
||||
if keyID == "" {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Key ID required"})
|
||||
return
|
||||
}
|
||||
var req apiKeyRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
|
||||
return
|
||||
}
|
||||
if req.ExpiresAt != "" && !validApiKeyTime(req.ExpiresAt) {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid expiration date"})
|
||||
return
|
||||
}
|
||||
for i := range config.AppConfig.ApiKeys {
|
||||
if config.AppConfig.ApiKeys[i].ID != keyID {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(req.Name) != "" {
|
||||
config.AppConfig.ApiKeys[i].Name = strings.TrimSpace(req.Name)
|
||||
}
|
||||
config.AppConfig.ApiKeys[i].IPWhitelist = strings.TrimSpace(req.IPWhitelist)
|
||||
if len(req.Scopes) > 0 {
|
||||
config.AppConfig.ApiKeys[i].Scopes = normalizeStringSlice(req.Scopes)
|
||||
}
|
||||
config.AppConfig.ApiKeys[i].ExpiresAt = strings.TrimSpace(req.ExpiresAt)
|
||||
config.AppConfig.ApiKeys[i].Disabled = req.Disabled
|
||||
config.AppConfig.ApiKeys[i].ContainerUUIDs = normalizeStringSlice(req.ContainerUUIDs)
|
||||
if err := config.SaveConfig(); err != nil {
|
||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to save API key"})
|
||||
return
|
||||
}
|
||||
auditRequest(r, "apikey.update", config.AppConfig.ApiKeys[i].Name, "scopes="+strings.Join(config.AppConfig.ApiKeys[i].Scopes, ","), true, "")
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: apiKeyResponse(config.AppConfig.ApiKeys[i])})
|
||||
return
|
||||
}
|
||||
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "API key not found"})
|
||||
}
|
||||
|
||||
func deleteApiKey(w http.ResponseWriter, r *http.Request) {
|
||||
keyID := apiKeyIDFromPath(r.URL.Path)
|
||||
if keyID == "" {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Key ID required"})
|
||||
return
|
||||
}
|
||||
name := keyID
|
||||
for _, k := range config.AppConfig.ApiKeys {
|
||||
if k.ID == keyID {
|
||||
name = k.Name
|
||||
break
|
||||
}
|
||||
}
|
||||
config.DeleteApiKey(keyID)
|
||||
auditRequest(r, "apikey.delete", name, "", true, "")
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "API key deleted"})
|
||||
}
|
||||
|
||||
func apiKeyResponse(k config.ApiKeyConfig) ApiKey {
|
||||
return ApiKey{
|
||||
ID: k.ID,
|
||||
Name: k.Name,
|
||||
Prefix: k.Prefix,
|
||||
IPWhitelist: k.IPWhitelist,
|
||||
CreatedAt: k.CreatedAt,
|
||||
LastUsed: k.LastUsed,
|
||||
Scopes: normalizeApiKeyScopes(k.Scopes),
|
||||
ExpiresAt: k.ExpiresAt,
|
||||
Disabled: k.Disabled,
|
||||
ContainerUUIDs: k.ContainerUUIDs,
|
||||
LastUsedIP: k.LastUsedIP,
|
||||
}
|
||||
}
|
||||
|
||||
func generateShortID() string {
|
||||
b := make([]byte, 4)
|
||||
rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// hashKey creates a simple hash for storage (not reversible)
|
||||
func hashKey(key string) string {
|
||||
sum := sha256.Sum256([]byte(key))
|
||||
return hex.EncodeToString(sum[:])
|
||||
const (
|
||||
apiKeyHashPrefix = "argon2id"
|
||||
apiKeyHashTime = uint32(3)
|
||||
apiKeyHashMemory = uint32(64 * 1024)
|
||||
apiKeyHashThreads = uint8(1)
|
||||
apiKeyHashSaltLength = 16
|
||||
apiKeyHashKeyLength = uint32(32)
|
||||
)
|
||||
|
||||
// hashAPIKey stores API keys using a salted slow password-hash style function.
|
||||
func hashAPIKey(key string) (string, error) {
|
||||
salt := make([]byte, apiKeyHashSaltLength)
|
||||
if _, err := rand.Read(salt); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hashAPIKeyWithSalt(key, salt), nil
|
||||
}
|
||||
|
||||
func hashAPIKeyWithSalt(key string, salt []byte) string {
|
||||
digest := argon2.IDKey([]byte(key), salt, apiKeyHashTime, apiKeyHashMemory, apiKeyHashThreads, apiKeyHashKeyLength)
|
||||
return fmt.Sprintf("%s$v=19$m=%d,t=%d,p=%d$%s$%s",
|
||||
apiKeyHashPrefix,
|
||||
apiKeyHashMemory,
|
||||
apiKeyHashTime,
|
||||
apiKeyHashThreads,
|
||||
hex.EncodeToString(salt),
|
||||
hex.EncodeToString(digest),
|
||||
)
|
||||
}
|
||||
|
||||
func verifyAPIKeyHash(rawKey, storedHash string) bool {
|
||||
parts := strings.Split(storedHash, "$")
|
||||
if len(parts) != 5 || parts[0] != apiKeyHashPrefix || parts[1] != "v=19" {
|
||||
return false
|
||||
}
|
||||
var memory, iterations uint32
|
||||
var threads uint8
|
||||
if _, err := fmt.Sscanf(parts[2], "m=%d,t=%d,p=%d", &memory, &iterations, &threads); err != nil {
|
||||
return false
|
||||
}
|
||||
if memory != apiKeyHashMemory || iterations != apiKeyHashTime || threads != apiKeyHashThreads {
|
||||
return false
|
||||
}
|
||||
salt, err := hex.DecodeString(parts[3])
|
||||
if err != nil || len(salt) == 0 {
|
||||
return false
|
||||
}
|
||||
expected, err := hex.DecodeString(parts[4])
|
||||
if err != nil || len(expected) == 0 {
|
||||
return false
|
||||
}
|
||||
digest := argon2.IDKey([]byte(rawKey), salt, iterations, memory, threads, uint32(len(expected)))
|
||||
return subtle.ConstantTimeCompare(digest, expected) == 1
|
||||
}
|
||||
|
||||
func legacyHashKey(key string) string {
|
||||
@@ -128,20 +299,75 @@ func legacyHashKey(key string) string {
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// validateApiKey checks if the given key is valid and IP is allowed
|
||||
func validateApiKey(rawKey, clientIP string) bool {
|
||||
hashed := hashKey(rawKey)
|
||||
func matchApiKey(rawKey string) (idx int, needsRehash bool) {
|
||||
legacyHashed := legacyHashKey(rawKey)
|
||||
for _, k := range config.AppConfig.ApiKeys {
|
||||
if subtle.ConstantTimeCompare([]byte(k.KeyHash), []byte(hashed)) == 1 ||
|
||||
subtle.ConstantTimeCompare([]byte(k.KeyHash), []byte(legacyHashed)) == 1 {
|
||||
if k.IPWhitelist == "" {
|
||||
return true
|
||||
}
|
||||
return isIPAllowed(clientIP, k.IPWhitelist)
|
||||
for i, k := range config.AppConfig.ApiKeys {
|
||||
if verifyAPIKeyHash(rawKey, k.KeyHash) {
|
||||
return i, false
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(k.KeyHash), []byte(legacyHashed)) == 1 {
|
||||
return i, true
|
||||
}
|
||||
}
|
||||
return false
|
||||
return -1, false
|
||||
}
|
||||
|
||||
// validateApiKey checks if the given key is valid and IP is allowed.
|
||||
func validateApiKey(rawKey, clientIP string) bool {
|
||||
_, ok := validateApiKeyDetails(rawKey, clientIP)
|
||||
return ok
|
||||
}
|
||||
|
||||
func validateApiKeyDetails(rawKey, clientIP string) (*config.ApiKeyConfig, bool) {
|
||||
idx, needsRehash := matchApiKey(rawKey)
|
||||
if idx < 0 {
|
||||
return nil, false
|
||||
}
|
||||
k := &config.AppConfig.ApiKeys[idx]
|
||||
if k.Disabled || apiKeyExpired(k.ExpiresAt) {
|
||||
return nil, false
|
||||
}
|
||||
if clientIP != "" && k.IPWhitelist != "" && !isIPAllowed(clientIP, k.IPWhitelist) {
|
||||
return nil, false
|
||||
}
|
||||
if needsRehash {
|
||||
if newHash, err := hashAPIKey(rawKey); err == nil {
|
||||
config.AppConfig.ApiKeys[idx].KeyHash = newHash
|
||||
config.SaveConfig()
|
||||
}
|
||||
}
|
||||
if len(k.Scopes) == 0 {
|
||||
k.Scopes = []string{"*"}
|
||||
}
|
||||
return k, true
|
||||
}
|
||||
|
||||
func validateApiKeyRequest(r *http.Request) (*config.ApiKeyConfig, bool) {
|
||||
apiKey := apiKeyFromRequest(r)
|
||||
if apiKey == "" {
|
||||
return nil, false
|
||||
}
|
||||
key, ok := validateApiKeyDetails(apiKey, clientIP(r))
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
updateApiKeyLastUsedForKey(key, clientIP(r))
|
||||
return key, true
|
||||
}
|
||||
|
||||
func authContextFromAPIKey(key *config.ApiKeyConfig) AuthContext {
|
||||
actor := "api:" + key.ID
|
||||
if key.Name != "" {
|
||||
actor = "api:" + key.Name
|
||||
}
|
||||
return AuthContext{
|
||||
Type: authTypeAPIKey,
|
||||
ApiKeyID: key.ID,
|
||||
ApiKeyName: key.Name,
|
||||
Actor: actor,
|
||||
Scopes: normalizeApiKeyScopes(key.Scopes),
|
||||
ContainerUUIDs: key.ContainerUUIDs,
|
||||
}
|
||||
}
|
||||
|
||||
func apiKeyFromRequest(r *http.Request) string {
|
||||
@@ -156,23 +382,16 @@ func apiKeyFromRequest(r *http.Request) string {
|
||||
}
|
||||
|
||||
func isValidApiKeyRequest(r *http.Request) bool {
|
||||
apiKey := apiKeyFromRequest(r)
|
||||
if apiKey == "" {
|
||||
return false
|
||||
}
|
||||
if !validateApiKey(apiKey, clientIP(r)) {
|
||||
return false
|
||||
}
|
||||
updateApiKeyLastUsed(apiKey)
|
||||
return true
|
||||
_, ok := validateApiKeyRequest(r)
|
||||
return ok
|
||||
}
|
||||
|
||||
// isIPAllowed checks if clientIP matches any entry in the whitelist
|
||||
func isIPAllowed(clientIP, whitelist string) bool {
|
||||
clientIP = strings.TrimSpace(clientIP)
|
||||
// Strip port if present
|
||||
if idx := strings.LastIndex(clientIP, ":"); idx > strings.LastIndex(clientIP, "]") {
|
||||
clientIP = clientIP[:idx]
|
||||
clientIP = normalizeIPString(clientIP)
|
||||
client := net.ParseIP(clientIP)
|
||||
if client == nil {
|
||||
return false
|
||||
}
|
||||
for _, entry := range strings.Split(whitelist, "\n") {
|
||||
entry = strings.TrimSpace(entry)
|
||||
@@ -180,77 +399,97 @@ func isIPAllowed(clientIP, whitelist string) bool {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(entry, "/") {
|
||||
// CIDR match
|
||||
if ipInCIDR(clientIP, entry) {
|
||||
_, network, err := net.ParseCIDR(entry)
|
||||
if err == nil && network.Contains(client) {
|
||||
return true
|
||||
}
|
||||
} else if entry == clientIP {
|
||||
continue
|
||||
}
|
||||
if allowed := net.ParseIP(normalizeIPString(entry)); allowed != nil && allowed.Equal(client) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func ipInCIDR(ipStr, cidr string) bool {
|
||||
parts := strings.Split(cidr, "/")
|
||||
if len(parts) != 2 {
|
||||
return false
|
||||
}
|
||||
// Simple prefix match for IPv4
|
||||
ip := netParseIP(ipStr)
|
||||
cidrIP := netParseIP(parts[0])
|
||||
if ip == nil || cidrIP == nil {
|
||||
return false
|
||||
}
|
||||
bits, err := strconv.Atoi(parts[1])
|
||||
if err != nil || bits < 0 || bits > 32 {
|
||||
return false
|
||||
}
|
||||
mask := uint32(0xFFFFFFFF) << (32 - bits)
|
||||
ipVal := ip4ToUint32(ip)
|
||||
cidrVal := ip4ToUint32(cidrIP)
|
||||
return (ipVal & mask) == (cidrVal & mask)
|
||||
}
|
||||
|
||||
func netParseIP(s string) net.IP {
|
||||
func normalizeIPString(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if idx := strings.LastIndex(s, ":"); idx > strings.LastIndex(s, "]") {
|
||||
s = s[:idx]
|
||||
if host, _, err := net.SplitHostPort(s); err == nil {
|
||||
return strings.Trim(host, "[]")
|
||||
}
|
||||
return net.ParseIP(s)
|
||||
return strings.Trim(s, "[]")
|
||||
}
|
||||
|
||||
func ip4ToUint32(ip net.IP) uint32 {
|
||||
ip = ip.To4()
|
||||
if ip == nil {
|
||||
return 0
|
||||
}
|
||||
return uint32(ip[0])<<24 | uint32(ip[1])<<16 | uint32(ip[2])<<8 | uint32(ip[3])
|
||||
func ipInCIDR(ipStr, cidr string) bool {
|
||||
ip := net.ParseIP(normalizeIPString(ipStr))
|
||||
_, network, err := net.ParseCIDR(cidr)
|
||||
return err == nil && ip != nil && network.Contains(ip)
|
||||
}
|
||||
|
||||
// updateApiKeyLastUsed marks the key as recently used
|
||||
// updateApiKeyLastUsed marks the key as recently used.
|
||||
func updateApiKeyLastUsed(rawKey string) {
|
||||
hashed := hashKey(rawKey)
|
||||
now := time.Now().Format("2006-01-02 15:04:05")
|
||||
for i := range config.AppConfig.ApiKeys {
|
||||
if config.AppConfig.ApiKeys[i].KeyHash == hashed {
|
||||
config.AppConfig.ApiKeys[i].LastUsed = now
|
||||
config.SaveConfig()
|
||||
return
|
||||
}
|
||||
key, ok := validateApiKeyDetails(rawKey, "")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
updateApiKeyLastUsedForKey(key, "")
|
||||
}
|
||||
|
||||
func updateApiKeyLastUsedForKey(key *config.ApiKeyConfig, ip string) {
|
||||
key.LastUsed = time.Now().Format("2006-01-02 15:04:05")
|
||||
if ip != "" {
|
||||
key.LastUsedIP = ip
|
||||
}
|
||||
config.SaveConfig()
|
||||
}
|
||||
|
||||
// ApiKeyMiddleware authenticates requests via X-API-Key header or Authorization bearer.
|
||||
func ApiKeyMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
apiKey := apiKeyFromRequest(r)
|
||||
if apiKey == "" || !validateApiKey(apiKey, clientIP(r)) {
|
||||
key, ok := validateApiKeyRequest(r)
|
||||
if !ok {
|
||||
jsonResponse(w, http.StatusUnauthorized, APIResponse{Success: false, Message: "Invalid API key or IP not in whitelist"})
|
||||
return
|
||||
}
|
||||
|
||||
updateApiKeyLastUsed(apiKey)
|
||||
next(w, r)
|
||||
next(w, withAuthContext(r, authContextFromAPIKey(key)))
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeApiKeyScopes(scopes []string) []string {
|
||||
return normalizeRequestedScopes(scopes, []string{"*"})
|
||||
}
|
||||
|
||||
func normalizeRequestedScopes(scopes []string, fallback []string) []string {
|
||||
result := normalizeStringSlice(scopes)
|
||||
if len(result) == 0 {
|
||||
return append([]string(nil), fallback...)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func normalizeStringSlice(values []string) []string {
|
||||
seen := map[string]bool{}
|
||||
result := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" || seen[value] {
|
||||
continue
|
||||
}
|
||||
seen[value] = true
|
||||
result = append(result, value)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func validApiKeyTime(value string) bool {
|
||||
_, err := time.ParseInLocation("2006-01-02 15:04:05", value, time.Local)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func apiKeyExpired(value string) bool {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return false
|
||||
}
|
||||
expiresAt, err := time.ParseInLocation("2006-01-02 15:04:05", value, time.Local)
|
||||
return err == nil && !time.Now().Before(expiresAt)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"clicd/internal/config"
|
||||
)
|
||||
|
||||
func TestHashAPIKeyUsesSaltedArgon2idHash(t *testing.T) {
|
||||
raw := "clicd_sk_0123456789abcdef0123456789abcdef"
|
||||
|
||||
h1, err := hashAPIKey(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h2, err := hashAPIKey(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if h1 == h2 {
|
||||
t.Fatal("expected salted hashes to differ")
|
||||
}
|
||||
if !strings.HasPrefix(h1, apiKeyHashPrefix+"$") || !strings.HasPrefix(h2, apiKeyHashPrefix+"$") {
|
||||
t.Fatalf("expected argon2id hashes, got %q and %q", h1, h2)
|
||||
}
|
||||
if !verifyAPIKeyHash(raw, h1) || !verifyAPIKeyHash(raw, h2) {
|
||||
t.Fatal("argon2id hashes did not verify")
|
||||
}
|
||||
if verifyAPIKeyHash(raw+"x", h1) {
|
||||
t.Fatal("argon2id hash verified wrong key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateApiKeyAllowsArgon2idAndUpdatesLastUsed(t *testing.T) {
|
||||
raw := "clicd_sk_0123456789abcdef0123456789abcdef"
|
||||
hash, err := hashAPIKey(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
config.AppConfig = &config.ClicdConfig{
|
||||
ApiKeys: []config.ApiKeyConfig{{
|
||||
ID: "key1",
|
||||
Name: "test",
|
||||
KeyHash: hash,
|
||||
}},
|
||||
}
|
||||
|
||||
if !validateApiKey(raw, "127.0.0.1") {
|
||||
t.Fatal("validateApiKey rejected valid argon2id key")
|
||||
}
|
||||
updateApiKeyLastUsed(raw)
|
||||
if config.AppConfig.ApiKeys[0].LastUsed == "" {
|
||||
t.Fatal("LastUsed was not updated")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateApiKeyMigratesLegacyHash(t *testing.T) {
|
||||
raw := "clicd_sk_0123456789abcdef0123456789abcdef"
|
||||
config.AppConfig = &config.ClicdConfig{
|
||||
ApiKeys: []config.ApiKeyConfig{{
|
||||
ID: "legacy",
|
||||
Name: "legacy",
|
||||
KeyHash: legacyHashKey(raw),
|
||||
}},
|
||||
}
|
||||
|
||||
if !validateApiKey(raw, "127.0.0.1") {
|
||||
t.Fatal("validateApiKey rejected valid legacy key")
|
||||
}
|
||||
migrated := config.AppConfig.ApiKeys[0].KeyHash
|
||||
if migrated == legacyHashKey(raw) {
|
||||
t.Fatal("legacy key hash was not migrated")
|
||||
}
|
||||
if !verifyAPIKeyHash(raw, migrated) {
|
||||
t.Fatal("migrated key hash does not verify")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateApiKeyAppliesIPWhitelist(t *testing.T) {
|
||||
raw := "clicd_sk_0123456789abcdef0123456789abcdef"
|
||||
hash, err := hashAPIKey(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
config.AppConfig = &config.ClicdConfig{
|
||||
ApiKeys: []config.ApiKeyConfig{{
|
||||
ID: "key1",
|
||||
Name: "test",
|
||||
KeyHash: hash,
|
||||
IPWhitelist: "192.0.2.10",
|
||||
}},
|
||||
}
|
||||
|
||||
if validateApiKey(raw, "198.51.100.10") {
|
||||
t.Fatal("validateApiKey allowed disallowed IP")
|
||||
}
|
||||
if !validateApiKey(raw, "192.0.2.10") {
|
||||
t.Fatal("validateApiKey rejected allowed IP")
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -28,6 +29,132 @@ type APIResponse struct {
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
type authContextKey struct{}
|
||||
|
||||
type AuthContext struct {
|
||||
Type string
|
||||
Username string
|
||||
ApiKeyID string
|
||||
ApiKeyName string
|
||||
Actor string
|
||||
Scopes []string
|
||||
ContainerUUIDs []string
|
||||
}
|
||||
|
||||
const (
|
||||
authTypeAdmin = "admin"
|
||||
authTypeSubUser = "sub_user"
|
||||
authTypeAPIKey = "api_key"
|
||||
)
|
||||
|
||||
func withAuthContext(r *http.Request, auth AuthContext) *http.Request {
|
||||
return r.WithContext(context.WithValue(r.Context(), authContextKey{}, auth))
|
||||
}
|
||||
|
||||
func authContextFromRequest(r *http.Request) (AuthContext, bool) {
|
||||
ctx, ok := r.Context().Value(authContextKey{}).(AuthContext)
|
||||
return ctx, ok
|
||||
}
|
||||
|
||||
func requestActor(r *http.Request) string {
|
||||
if ctx, ok := authContextFromRequest(r); ok && ctx.Actor != "" {
|
||||
return ctx.Actor
|
||||
}
|
||||
if claims, ok := claimsFromRequest(r); ok {
|
||||
if subUser, _ := claims["sub_user"].(string); subUser != "" {
|
||||
return "user:" + subUser
|
||||
}
|
||||
if username, _ := claims["username"].(string); username != "" {
|
||||
return username
|
||||
}
|
||||
}
|
||||
return "admin"
|
||||
}
|
||||
|
||||
func hasScope(r *http.Request, scope string) bool {
|
||||
ctx, ok := authContextFromRequest(r)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
switch ctx.Type {
|
||||
case authTypeAdmin:
|
||||
return true
|
||||
case authTypeSubUser:
|
||||
return subUserScopeAllowed(scope)
|
||||
case authTypeAPIKey:
|
||||
return scopeAllowed(ctx.Scopes, scope)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func subUserScopeAllowed(scope string) bool {
|
||||
switch scope {
|
||||
case "container:read", "container:power", "container:reinstall", "container:network",
|
||||
"dashboard:read", "image:read", "task:read", "snapshot:read", "snapshot:create", "snapshot:delete", "snapshot:restore", "snapshot:schedule",
|
||||
"terminal:ssh", "terminal:vnc":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func hasAnyScope(r *http.Request, scopes ...string) bool {
|
||||
for _, scope := range scopes {
|
||||
if hasScope(r, scope) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func scopeAllowed(scopes []string, required string) bool {
|
||||
for _, scope := range scopes {
|
||||
scope = strings.TrimSpace(scope)
|
||||
if scope == "*" || scope == "admin:*" || scope == required {
|
||||
return true
|
||||
}
|
||||
if strings.HasSuffix(scope, ":*") {
|
||||
prefix := strings.TrimSuffix(scope, "*")
|
||||
if strings.HasPrefix(required, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func requireScope(w http.ResponseWriter, r *http.Request, scope string) bool {
|
||||
if hasScope(r, scope) {
|
||||
return true
|
||||
}
|
||||
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Insufficient API key scope"})
|
||||
return false
|
||||
}
|
||||
|
||||
func ScopeMiddleware(scope string, next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireScope(w, r, scope) {
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func AnyScopeMiddleware(scopes []string, next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if hasAnyScope(r, scopes...) {
|
||||
next(w, r)
|
||||
return
|
||||
}
|
||||
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Insufficient API key scope"})
|
||||
}
|
||||
}
|
||||
|
||||
func auditRequest(r *http.Request, action, target, detail string, success bool, errMsg string) {
|
||||
config.AddAuditLogFull(action, target, detail, requestActor(r), clientIP(r), r.UserAgent(), success, errMsg)
|
||||
}
|
||||
|
||||
func jsonResponse(w http.ResponseWriter, status int, resp APIResponse) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
@@ -75,8 +202,10 @@ func claimsFromToken(tokenString string) (jwt.MapClaims, bool) {
|
||||
if subUser, _ := claims["sub_user"].(string); subUser != "" {
|
||||
tokenVersionFloat, hasVersion := claims["token_version"].(float64)
|
||||
tokenVersion := int(tokenVersionFloat)
|
||||
foundSubUser := false
|
||||
for i := range config.AppConfig.SubUsers {
|
||||
if config.AppConfig.SubUsers[i].Username == subUser {
|
||||
foundSubUser = true
|
||||
stored := config.AppConfig.SubUsers[i].TokenVersion
|
||||
// If stored version > 0, require token_version to match exactly.
|
||||
// This also rejects legacy tokens that lack token_version entirely.
|
||||
@@ -86,6 +215,9 @@ func claimsFromToken(tokenString string) (jwt.MapClaims, bool) {
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundSubUser {
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
return claims, ok
|
||||
@@ -96,6 +228,9 @@ func claimsFromRequest(r *http.Request) (jwt.MapClaims, bool) {
|
||||
}
|
||||
|
||||
func isSubUserRequest(r *http.Request) bool {
|
||||
if ctx, ok := authContextFromRequest(r); ok {
|
||||
return ctx.Type == authTypeSubUser
|
||||
}
|
||||
claims, ok := claimsFromRequest(r)
|
||||
if !ok {
|
||||
return false
|
||||
@@ -210,19 +345,45 @@ func HandleCheckAuth(w http.ResponseWriter, r *http.Request) {
|
||||
func AuthMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
tokenString := tokenFromRequest(r)
|
||||
if !isValidToken(tokenString) && !isValidApiKeyRequest(r) {
|
||||
jsonResponse(w, http.StatusUnauthorized, APIResponse{Success: false, Message: "Authentication required"})
|
||||
if claims, ok := claimsFromToken(tokenString); ok {
|
||||
if subUser, _ := claims["sub_user"].(string); subUser != "" {
|
||||
auth := AuthContext{Type: authTypeSubUser, Username: subUser, Actor: "user:" + subUser}
|
||||
if values, ok := claims["container_uuids"].([]interface{}); ok {
|
||||
for _, value := range values {
|
||||
if uuid, ok := value.(string); ok {
|
||||
auth.ContainerUUIDs = append(auth.ContainerUUIDs, uuid)
|
||||
}
|
||||
}
|
||||
}
|
||||
next(w, withAuthContext(r, auth))
|
||||
return
|
||||
}
|
||||
username, _ := claims["username"].(string)
|
||||
if username == "" {
|
||||
username = config.AppConfig.AdminUser
|
||||
}
|
||||
next(w, withAuthContext(r, AuthContext{Type: authTypeAdmin, Username: username, Actor: username}))
|
||||
return
|
||||
}
|
||||
|
||||
next(w, r)
|
||||
if key, ok := validateApiKeyRequest(r); ok {
|
||||
next(w, withAuthContext(r, authContextFromAPIKey(key)))
|
||||
return
|
||||
}
|
||||
|
||||
jsonResponse(w, http.StatusUnauthorized, APIResponse{Success: false, Message: "Authentication required"})
|
||||
}
|
||||
}
|
||||
|
||||
// AdminMiddleware requires a valid administrator token and rejects sub-user tokens.
|
||||
func AdminMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
||||
return AuthMiddleware(func(w http.ResponseWriter, r *http.Request) {
|
||||
if isSubUserRequest(r) {
|
||||
ctx, _ := authContextFromRequest(r)
|
||||
if ctx.Type == authTypeSubUser {
|
||||
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Administrator permission required"})
|
||||
return
|
||||
}
|
||||
if ctx.Type == authTypeAPIKey && !scopeAllowed(ctx.Scopes, "admin:access") {
|
||||
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Administrator permission required"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
//go:build !linux
|
||||
|
||||
package api
|
||||
|
||||
func getRootDiskInfo() (DiskInfo, bool) {
|
||||
return DiskInfo{}, false
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
//go:build linux
|
||||
|
||||
package api
|
||||
|
||||
import "golang.org/x/sys/unix"
|
||||
|
||||
func getRootDiskInfo() (DiskInfo, bool) {
|
||||
var stat unix.Statfs_t
|
||||
if err := unix.Statfs("/", &stat); err != nil {
|
||||
return DiskInfo{}, false
|
||||
}
|
||||
|
||||
total := float64(int64(stat.Blocks)*int64(stat.Bsize)) / (1024 * 1024 * 1024)
|
||||
free := float64(int64(stat.Bavail)*int64(stat.Bsize)) / (1024 * 1024 * 1024)
|
||||
|
||||
return DiskInfo{
|
||||
TotalGB: total,
|
||||
UsedGB: total - free,
|
||||
FreeGB: free,
|
||||
}, true
|
||||
}
|
||||
@@ -2,10 +2,12 @@ package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"clicd/internal/config"
|
||||
"clicd/internal/lxc"
|
||||
@@ -18,17 +20,41 @@ var lxcManager = lxc.NewManager()
|
||||
func HandleContainers(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
if !requireScope(w, r, "container:read") {
|
||||
return
|
||||
}
|
||||
listContainers(w, r)
|
||||
case http.MethodPost:
|
||||
if !requireScope(w, r, "container:create") {
|
||||
return
|
||||
}
|
||||
if isAccessRestrictedRequest(r) {
|
||||
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Container-bound API keys cannot create containers"})
|
||||
return
|
||||
}
|
||||
createContainer(w, r)
|
||||
default:
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||
}
|
||||
}
|
||||
|
||||
// HandleContainerListAlias supports legacy integrations that call
|
||||
// /api/containers/list or /api/v1/containers/list.
|
||||
func HandleContainerListAlias(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodPost {
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||
return
|
||||
}
|
||||
if !requireScope(w, r, "container:read") {
|
||||
return
|
||||
}
|
||||
listContainers(w, r)
|
||||
}
|
||||
|
||||
// HandleSingleContainer handles individual container operations by ID or name: /api/containers/{id-or-name}/...
|
||||
func HandleSingleContainer(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api/containers/")
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api/v1/containers/")
|
||||
path = strings.TrimPrefix(path, "/api/containers/")
|
||||
parts := strings.SplitN(path, "/", 2)
|
||||
c := containerByIdentifier(parts[0])
|
||||
id := 0
|
||||
@@ -48,6 +74,10 @@ func HandleSingleContainer(w http.ResponseWriter, r *http.Request) {
|
||||
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"})
|
||||
return
|
||||
}
|
||||
if !isSnapshotAction && !isContainerAllowedForRequest(r, parts[0]) {
|
||||
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Access denied to this container"})
|
||||
return
|
||||
}
|
||||
if isSnapshotAction && id == 0 {
|
||||
// For orphaned snapshots, resolve containerID from the snapshot itself
|
||||
snapshotID := strings.TrimPrefix(action, "snapshots/")
|
||||
@@ -59,45 +89,105 @@ func HandleSingleContainer(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
id = snapshot.ContainerID
|
||||
}
|
||||
if isSnapshotAction {
|
||||
if c := config.FindContainer(id); c != nil && !isContainerAllowedForRequest(r, c.UUID) {
|
||||
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Access denied to this container"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
switch {
|
||||
case action == "start" && r.Method == http.MethodPost:
|
||||
if !requireScope(w, r, "container:power") {
|
||||
return
|
||||
}
|
||||
HandleSingleTaskAction(w, r, id, "start")
|
||||
case action == "stop" && r.Method == http.MethodPost:
|
||||
if !requireScope(w, r, "container:power") {
|
||||
return
|
||||
}
|
||||
HandleSingleTaskAction(w, r, id, "stop")
|
||||
case action == "restart" && r.Method == http.MethodPost:
|
||||
if !requireScope(w, r, "container:power") {
|
||||
return
|
||||
}
|
||||
HandleSingleTaskAction(w, r, id, "restart")
|
||||
case action == "reinstall" && r.Method == http.MethodPost:
|
||||
if !requireScope(w, r, "container:reinstall") {
|
||||
return
|
||||
}
|
||||
HandleSingleTaskAction(w, r, id, "reinstall")
|
||||
case action == "delete" && r.Method == http.MethodDelete:
|
||||
if !requireScope(w, r, "container:delete") {
|
||||
return
|
||||
}
|
||||
HandleSingleTaskAction(w, r, id, "delete")
|
||||
case action == "reset-password" && r.Method == http.MethodPost:
|
||||
if !requireScope(w, r, "container:password") {
|
||||
return
|
||||
}
|
||||
resetSSHPassword(w, r, id)
|
||||
case action == "usage" && r.Method == http.MethodGet:
|
||||
if !requireScope(w, r, "container:read") {
|
||||
return
|
||||
}
|
||||
getUsage(w, r, id)
|
||||
case action == "traffic" && r.Method == http.MethodGet:
|
||||
if !requireScope(w, r, "container:read") {
|
||||
return
|
||||
}
|
||||
getTraffic(w, r, id)
|
||||
case action == "traffic-reset" && r.Method == http.MethodPost:
|
||||
if !requireScope(w, r, "container:traffic") {
|
||||
return
|
||||
}
|
||||
resetTraffic(w, r, id)
|
||||
case action == "traffic-limit" && r.Method == http.MethodPut:
|
||||
if !requireScope(w, r, "container:traffic") {
|
||||
return
|
||||
}
|
||||
updateTrafficLimit(w, r, id)
|
||||
case action == "resource-limit" && r.Method == http.MethodPut:
|
||||
if !requireScope(w, r, "container:resize") {
|
||||
return
|
||||
}
|
||||
updateResourceLimit(w, r, id)
|
||||
case action == "random-port" && r.Method == http.MethodGet:
|
||||
if !requireScope(w, r, "container:network") {
|
||||
return
|
||||
}
|
||||
getRandomPort(w, r, id)
|
||||
case action == "expiry" && r.Method == http.MethodPut:
|
||||
if !requireScope(w, r, "container:resize") {
|
||||
return
|
||||
}
|
||||
updateExpiry(w, r, id)
|
||||
case action == "ipv6" && r.Method == http.MethodPost:
|
||||
if !requireScope(w, r, "ipv6:assign") {
|
||||
return
|
||||
}
|
||||
assignIPv6(w, r, id)
|
||||
case action == "snapshots" || strings.HasPrefix(action, "snapshots/"):
|
||||
handleContainerSnapshots(w, r, id, action)
|
||||
case action == "port-mappings" && r.Method == http.MethodPost:
|
||||
if !requireScope(w, r, "container:network") {
|
||||
return
|
||||
}
|
||||
addPortMapping(w, r, id)
|
||||
case strings.HasPrefix(action, "port-mappings/") && r.Method == http.MethodPut:
|
||||
if !requireScope(w, r, "container:network") {
|
||||
return
|
||||
}
|
||||
updatePortMapping(w, r, id, strings.TrimPrefix(action, "port-mappings/"))
|
||||
case strings.HasPrefix(action, "port-mappings/") && r.Method == http.MethodDelete:
|
||||
if !requireScope(w, r, "container:network") {
|
||||
return
|
||||
}
|
||||
deletePortMapping(w, r, id, strings.TrimPrefix(action, "port-mappings/"))
|
||||
case r.Method == http.MethodGet:
|
||||
if !requireScope(w, r, "container:read") {
|
||||
return
|
||||
}
|
||||
getContainer(w, r, id)
|
||||
default:
|
||||
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Action not found"})
|
||||
@@ -346,6 +436,9 @@ func HandleTemplates(w http.ResponseWriter, r *http.Request) {
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||
return
|
||||
}
|
||||
if !requireScope(w, r, "image:read") {
|
||||
return
|
||||
}
|
||||
if isSubUserRequest(r) {
|
||||
HandleEnabledImages(w, r)
|
||||
return
|
||||
@@ -360,7 +453,11 @@ func HandleDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||
return
|
||||
}
|
||||
if !requireScope(w, r, "dashboard:read") {
|
||||
return
|
||||
}
|
||||
containers, _ := listByRuntime()
|
||||
containers = filterContainersForRequest(r, containers)
|
||||
running := 0
|
||||
stopped := 0
|
||||
for _, c := range containers {
|
||||
@@ -384,6 +481,9 @@ func HandleHostInfo(w http.ResponseWriter, r *http.Request) {
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||
return
|
||||
}
|
||||
if !requireScope(w, r, "host:read") {
|
||||
return
|
||||
}
|
||||
info := getHostInfo()
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: info})
|
||||
}
|
||||
@@ -394,7 +494,24 @@ func resetSSHPassword(w http.ResponseWriter, r *http.Request, id int) {
|
||||
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "容器已到期,不允许此操作"})
|
||||
return
|
||||
}
|
||||
newPassword, err := resetPasswordByRuntime(id)
|
||||
var req struct {
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if r.Body != nil {
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
if err := decoder.Decode(&req); err != nil && err.Error() != "EOF" {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
|
||||
return
|
||||
}
|
||||
}
|
||||
password := strings.TrimSpace(req.Password)
|
||||
if password != "" {
|
||||
if err := validateSSHPassword(password); err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
newPassword, err := resetPasswordByRuntime(id, password)
|
||||
if err != nil {
|
||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
|
||||
return
|
||||
@@ -406,6 +523,29 @@ func resetSSHPassword(w http.ResponseWriter, r *http.Request, id int) {
|
||||
})
|
||||
}
|
||||
|
||||
func validateSSHPassword(password string) error {
|
||||
if len(password) < 8 || len(password) > 64 {
|
||||
return fmt.Errorf("密码长度必须为 8-64 位")
|
||||
}
|
||||
hasLetter := false
|
||||
hasDigit := false
|
||||
for _, r := range password {
|
||||
if unicode.IsSpace(r) {
|
||||
return fmt.Errorf("密码不能包含空白字符")
|
||||
}
|
||||
if unicode.IsLetter(r) {
|
||||
hasLetter = true
|
||||
}
|
||||
if unicode.IsDigit(r) {
|
||||
hasDigit = true
|
||||
}
|
||||
}
|
||||
if !hasLetter || !hasDigit {
|
||||
return fmt.Errorf("密码至少需要包含字母和数字")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func addPortMapping(w http.ResponseWriter, r *http.Request, id int) {
|
||||
var pm config.PortMapping
|
||||
if err := json.NewDecoder(r.Body).Decode(&pm); err != nil {
|
||||
|
||||
+1236
-14
File diff suppressed because it is too large
Load Diff
+294
-92
@@ -1,6 +1,7 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -8,6 +9,7 @@ import (
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"clicd/internal/config"
|
||||
"clicd/internal/kvm"
|
||||
@@ -16,22 +18,143 @@ import (
|
||||
|
||||
// ImageInfo represents a template image with its download/enable status.
|
||||
type ImageInfo struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Distro string `json:"distro"`
|
||||
Release string `json:"release"`
|
||||
Arch string `json:"arch"`
|
||||
Description string `json:"description"`
|
||||
Downloaded bool `json:"downloaded"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Downloading bool `json:"downloading"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
ManualPath string `json:"manual_path,omitempty"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Distro string `json:"distro"`
|
||||
Release string `json:"release"`
|
||||
Arch string `json:"arch"`
|
||||
Description string `json:"description"`
|
||||
Downloaded bool `json:"downloaded"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Downloading bool `json:"downloading"`
|
||||
Progress int `json:"progress"`
|
||||
DownloadedBytes int64 `json:"downloaded_bytes"`
|
||||
TotalBytes int64 `json:"total_bytes"`
|
||||
Stage string `json:"stage,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
ManualPath string `json:"manual_path,omitempty"`
|
||||
Desktop string `json:"desktop,omitempty"`
|
||||
}
|
||||
|
||||
var imageDownloadsMu sync.Mutex
|
||||
var imageDownloads = map[string]bool{}
|
||||
var imageDownloads = map[string]*imageDownloadStatus{}
|
||||
|
||||
type imageDownloadStatus struct {
|
||||
Downloading bool
|
||||
Progress int
|
||||
DownloadedBytes int64
|
||||
TotalBytes int64
|
||||
Stage string
|
||||
Error string
|
||||
Cancel context.CancelFunc
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type imageDownloadSnapshot struct {
|
||||
Downloading bool
|
||||
Progress int
|
||||
DownloadedBytes int64
|
||||
TotalBytes int64
|
||||
Stage string
|
||||
Error string
|
||||
}
|
||||
|
||||
func imageDownloadInfo(id string) imageDownloadSnapshot {
|
||||
imageDownloadsMu.Lock()
|
||||
defer imageDownloadsMu.Unlock()
|
||||
st := imageDownloads[id]
|
||||
if st == nil {
|
||||
return imageDownloadSnapshot{}
|
||||
}
|
||||
return imageDownloadSnapshot{
|
||||
Downloading: st.Downloading,
|
||||
Progress: st.Progress,
|
||||
DownloadedBytes: st.DownloadedBytes,
|
||||
TotalBytes: st.TotalBytes,
|
||||
Stage: st.Stage,
|
||||
Error: st.Error,
|
||||
}
|
||||
}
|
||||
|
||||
func startImageDownload(id, stage string) (context.Context, bool) {
|
||||
imageDownloadsMu.Lock()
|
||||
defer imageDownloadsMu.Unlock()
|
||||
if st := imageDownloads[id]; st != nil && st.Downloading {
|
||||
return nil, false
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
imageDownloads[id] = &imageDownloadStatus{
|
||||
Downloading: true,
|
||||
Stage: stage,
|
||||
Cancel: cancel,
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
return ctx, true
|
||||
}
|
||||
|
||||
func updateImageDownload(id string, update func(*imageDownloadStatus)) {
|
||||
imageDownloadsMu.Lock()
|
||||
defer imageDownloadsMu.Unlock()
|
||||
st := imageDownloads[id]
|
||||
if st == nil {
|
||||
return
|
||||
}
|
||||
update(st)
|
||||
st.UpdatedAt = time.Now()
|
||||
}
|
||||
|
||||
func finishImageDownload(id string, err error) {
|
||||
imageDownloadsMu.Lock()
|
||||
defer imageDownloadsMu.Unlock()
|
||||
st := imageDownloads[id]
|
||||
if st == nil {
|
||||
return
|
||||
}
|
||||
st.Downloading = false
|
||||
st.Cancel = nil
|
||||
st.UpdatedAt = time.Now()
|
||||
if err != nil {
|
||||
st.Error = err.Error()
|
||||
return
|
||||
}
|
||||
delete(imageDownloads, id)
|
||||
}
|
||||
|
||||
func clearImageDownload(id string) {
|
||||
imageDownloadsMu.Lock()
|
||||
delete(imageDownloads, id)
|
||||
imageDownloadsMu.Unlock()
|
||||
}
|
||||
|
||||
func isImageDownloadActive(id string) bool {
|
||||
imageDownloadsMu.Lock()
|
||||
defer imageDownloadsMu.Unlock()
|
||||
st := imageDownloads[id]
|
||||
return st != nil && st.Downloading
|
||||
}
|
||||
|
||||
func lxcImageDownloadTempName(id string) string {
|
||||
return fmt.Sprintf("clicd-img-dl-%s", id)
|
||||
}
|
||||
|
||||
func cleanupLXCImageDownloadTemp(id string) {
|
||||
tmpName := lxcImageDownloadTempName(id)
|
||||
exec.Command("lxc-destroy", "-n", tmpName, "-f").Run()
|
||||
os.RemoveAll(filepath.Join("/var/lib/lxc", tmpName))
|
||||
}
|
||||
|
||||
func cleanupOldImageDownloadErrors() {
|
||||
imageDownloadsMu.Lock()
|
||||
defer imageDownloadsMu.Unlock()
|
||||
cutoff := time.Now().Add(-10 * time.Minute)
|
||||
for id, st := range imageDownloads {
|
||||
if !st.Downloading && st.UpdatedAt.Before(cutoff) {
|
||||
delete(imageDownloads, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// isImageDownloaded checks if the LXC download cache exists for a template.
|
||||
func isImageDownloaded(distro, release, arch string) bool {
|
||||
@@ -98,60 +221,78 @@ func HandleImages(w http.ResponseWriter, r *http.Request) {
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||
return
|
||||
}
|
||||
if !requireScope(w, r, "image:read") {
|
||||
return
|
||||
}
|
||||
|
||||
enabledSet := getEnabledImageSet()
|
||||
cleanupOldImageDownloadErrors()
|
||||
|
||||
templates := lxc.GetTemplates()
|
||||
images := make([]ImageInfo, 0, len(templates)+len(kvm.GetImages()))
|
||||
for _, t := range templates {
|
||||
_, downloading := imageDownloads[t.ID]
|
||||
dl := imageDownloadInfo(t.ID)
|
||||
downloaded, size := imageDownloadedInfo(t.Distro, t.Release, t.Arch)
|
||||
images = append(images, ImageInfo{
|
||||
ID: t.ID,
|
||||
Name: t.Name,
|
||||
Type: config.VirtualizationLXC,
|
||||
Distro: t.Distro,
|
||||
Release: t.Release,
|
||||
Arch: t.Arch,
|
||||
Description: t.Description,
|
||||
Downloaded: downloaded,
|
||||
Enabled: enabledSet[t.ID],
|
||||
Downloading: downloading,
|
||||
SizeBytes: size,
|
||||
ID: t.ID,
|
||||
Name: t.Name,
|
||||
Type: config.VirtualizationLXC,
|
||||
Distro: t.Distro,
|
||||
Release: t.Release,
|
||||
Arch: t.Arch,
|
||||
Description: t.Description,
|
||||
Downloaded: downloaded,
|
||||
Enabled: enabledSet[t.ID],
|
||||
Downloading: dl.Downloading,
|
||||
Progress: dl.Progress,
|
||||
DownloadedBytes: dl.DownloadedBytes,
|
||||
TotalBytes: dl.TotalBytes,
|
||||
Stage: dl.Stage,
|
||||
Error: dl.Error,
|
||||
SizeBytes: size,
|
||||
})
|
||||
}
|
||||
for _, t := range kvm.GetImages() {
|
||||
_, downloading := imageDownloads[t.ID]
|
||||
dl := imageDownloadInfo(t.ID)
|
||||
downloaded, size := kvm.ImageDownloadedInfo(t.ID)
|
||||
manualPath := ""
|
||||
if t.Distro == "windows" {
|
||||
manualPath = kvm.ImagePath(t.ID)
|
||||
}
|
||||
images = append(images, ImageInfo{
|
||||
ID: t.ID,
|
||||
Name: t.Name,
|
||||
Type: config.VirtualizationKVM,
|
||||
Distro: t.Distro,
|
||||
Release: t.Release,
|
||||
Arch: t.Arch,
|
||||
Description: t.Description,
|
||||
Downloaded: downloaded,
|
||||
Enabled: enabledSet[t.ID],
|
||||
Downloading: downloading,
|
||||
SizeBytes: size,
|
||||
ManualPath: manualPath,
|
||||
ID: t.ID,
|
||||
Name: t.Name,
|
||||
Type: config.VirtualizationKVM,
|
||||
Distro: t.Distro,
|
||||
Release: t.Release,
|
||||
Arch: t.Arch,
|
||||
Description: t.Description,
|
||||
Downloaded: downloaded,
|
||||
Enabled: enabledSet[t.ID],
|
||||
Downloading: dl.Downloading,
|
||||
Progress: dl.Progress,
|
||||
DownloadedBytes: dl.DownloadedBytes,
|
||||
TotalBytes: dl.TotalBytes,
|
||||
Stage: dl.Stage,
|
||||
Error: dl.Error,
|
||||
SizeBytes: size,
|
||||
ManualPath: manualPath,
|
||||
Desktop: t.Desktop,
|
||||
})
|
||||
}
|
||||
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: images})
|
||||
}
|
||||
|
||||
// HandleImageDownload downloads a template image from the LXC image server.
|
||||
// HandleImageDownload starts a template image download in the background.
|
||||
func HandleImageDownload(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||
return
|
||||
}
|
||||
if !requireScope(w, r, "image:download") {
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
TemplateID string `json:"template_id"`
|
||||
@@ -170,82 +311,130 @@ func HandleImageDownload(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
if ok, _ := kvm.ImageDownloadedInfo(image.ID); ok {
|
||||
ensureImageEnabled(image.ID)
|
||||
clearImageDownload(image.ID)
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Already downloaded"})
|
||||
return
|
||||
}
|
||||
imageDownloadsMu.Lock()
|
||||
if imageDownloads[req.TemplateID] {
|
||||
imageDownloadsMu.Unlock()
|
||||
ctx, ok := startImageDownload(image.ID, "downloading")
|
||||
if !ok {
|
||||
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: "Already downloading"})
|
||||
return
|
||||
}
|
||||
imageDownloads[req.TemplateID] = true
|
||||
imageDownloadsMu.Unlock()
|
||||
defer func() {
|
||||
imageDownloadsMu.Lock()
|
||||
delete(imageDownloads, req.TemplateID)
|
||||
imageDownloadsMu.Unlock()
|
||||
}()
|
||||
ensureImageEnabled(image.ID)
|
||||
if err := kvm.DownloadImage(*image); err != nil {
|
||||
message := "Download failed: " + err.Error()
|
||||
|
||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: message})
|
||||
return
|
||||
}
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Downloaded successfully"})
|
||||
go func(image kvm.Image) {
|
||||
err := kvm.DownloadImageWithProgress(ctx, image, func(p kvm.DownloadProgress) {
|
||||
updateImageDownload(image.ID, func(st *imageDownloadStatus) {
|
||||
if p.Stage != "" {
|
||||
st.Stage = p.Stage
|
||||
}
|
||||
if p.DownloadedBytes > 0 || p.TotalBytes > 0 {
|
||||
st.DownloadedBytes = p.DownloadedBytes
|
||||
st.TotalBytes = p.TotalBytes
|
||||
}
|
||||
st.Progress = p.Percent
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
os.Remove(kvm.ImagePath(image.ID) + ".tmp")
|
||||
os.Remove(kvm.ImagePath(image.ID))
|
||||
finishImageDownload(image.ID, nil)
|
||||
return
|
||||
}
|
||||
finishImageDownload(image.ID, err)
|
||||
return
|
||||
}
|
||||
ensureImageEnabled(image.ID)
|
||||
finishImageDownload(image.ID, nil)
|
||||
}(*image)
|
||||
jsonResponse(w, http.StatusAccepted, APIResponse{Success: true, Message: "Download started"})
|
||||
return
|
||||
}
|
||||
|
||||
// Already downloaded? Just enable if needed.
|
||||
if isImageDownloaded(tmpl.Distro, tmpl.Release, tmpl.Arch) {
|
||||
ensureImageEnabled(tmpl.ID)
|
||||
clearImageDownload(tmpl.ID)
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Already downloaded"})
|
||||
return
|
||||
}
|
||||
|
||||
// Already downloading?
|
||||
imageDownloadsMu.Lock()
|
||||
if imageDownloads[req.TemplateID] {
|
||||
imageDownloadsMu.Unlock()
|
||||
ctx, ok := startImageDownload(tmpl.ID, "lxc-create")
|
||||
if !ok {
|
||||
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: "Already downloading"})
|
||||
return
|
||||
}
|
||||
imageDownloads[req.TemplateID] = true
|
||||
imageDownloadsMu.Unlock()
|
||||
|
||||
defer func() {
|
||||
imageDownloadsMu.Lock()
|
||||
delete(imageDownloads, req.TemplateID)
|
||||
imageDownloadsMu.Unlock()
|
||||
}()
|
||||
|
||||
// Auto-enable on download
|
||||
ensureImageEnabled(tmpl.ID)
|
||||
|
||||
// Download via lxc-create with a temp container, then destroy it.
|
||||
tmpName := fmt.Sprintf("clicd-img-dl-%s", tmpl.ID)
|
||||
args := []string{"-n", tmpName, "-t", "download", "--",
|
||||
"-d", tmpl.Distro, "-r", tmpl.Release, "-a", tmpl.Arch}
|
||||
if tmpl.Variant != "" {
|
||||
args = append(args, "--variant", tmpl.Variant)
|
||||
}
|
||||
cmd := exec.Command("lxc-create", args...)
|
||||
output, err := cmd.CombinedOutput()
|
||||
|
||||
// Clean up the temp container unconditionally.
|
||||
exec.Command("lxc-destroy", "-n", tmpName, "-f").Run()
|
||||
os.RemoveAll(filepath.Join("/var/lib/lxc", tmpName))
|
||||
|
||||
if err != nil {
|
||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{
|
||||
Success: false,
|
||||
Message: fmt.Sprintf("Download failed: %v, output: %s", err, string(output)),
|
||||
go func(tmpl lxc.Template) {
|
||||
// Download via lxc-create with a temp container, then destroy it.
|
||||
tmpName := lxcImageDownloadTempName(tmpl.ID)
|
||||
args := []string{"-n", tmpName, "-t", "download", "--",
|
||||
"-d", tmpl.Distro, "-r", tmpl.Release, "-a", tmpl.Arch}
|
||||
if tmpl.Variant != "" {
|
||||
args = append(args, "--variant", tmpl.Variant)
|
||||
}
|
||||
updateImageDownload(tmpl.ID, func(st *imageDownloadStatus) {
|
||||
st.Stage = "lxc-create"
|
||||
})
|
||||
cmd := exec.CommandContext(ctx, "lxc-create", args...)
|
||||
output, err := cmd.CombinedOutput()
|
||||
|
||||
// Clean up the temp container unconditionally.
|
||||
cleanupLXCImageDownloadTemp(tmpl.ID)
|
||||
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
finishImageDownload(tmpl.ID, nil)
|
||||
return
|
||||
}
|
||||
err = fmt.Errorf("Download failed: %v, output: %s", err, string(output))
|
||||
finishImageDownload(tmpl.ID, err)
|
||||
return
|
||||
}
|
||||
ensureImageEnabled(tmpl.ID)
|
||||
finishImageDownload(tmpl.ID, nil)
|
||||
}(*tmpl)
|
||||
|
||||
jsonResponse(w, http.StatusAccepted, APIResponse{Success: true, Message: "Download started"})
|
||||
}
|
||||
|
||||
// HandleImageCancel cancels an in-progress image download.
|
||||
func HandleImageCancel(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||
return
|
||||
}
|
||||
if !requireScope(w, r, "image:download") {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
TemplateID string `json:"template_id"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.TemplateID == "" {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "template_id required"})
|
||||
return
|
||||
}
|
||||
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Downloaded successfully"})
|
||||
imageDownloadsMu.Lock()
|
||||
st := imageDownloads[req.TemplateID]
|
||||
if st == nil || !st.Downloading || st.Cancel == nil {
|
||||
imageDownloadsMu.Unlock()
|
||||
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "No active download"})
|
||||
return
|
||||
}
|
||||
cancel := st.Cancel
|
||||
st.Stage = "canceling"
|
||||
st.UpdatedAt = time.Now()
|
||||
imageDownloadsMu.Unlock()
|
||||
|
||||
cancel()
|
||||
if image := kvm.FindImage(req.TemplateID); image != nil {
|
||||
os.Remove(kvm.ImagePath(image.ID) + ".tmp")
|
||||
os.Remove(kvm.ImagePath(image.ID))
|
||||
}
|
||||
if tmpl := lxc.FindTemplate(req.TemplateID); tmpl != nil {
|
||||
go cleanupLXCImageDownloadTemp(tmpl.ID)
|
||||
}
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Cancel requested"})
|
||||
}
|
||||
|
||||
// HandleImageDelete deletes a cached template image from disk.
|
||||
@@ -254,6 +443,9 @@ func HandleImageDelete(w http.ResponseWriter, r *http.Request) {
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||
return
|
||||
}
|
||||
if !requireScope(w, r, "image:delete") {
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
TemplateID string `json:"template_id"`
|
||||
@@ -262,6 +454,10 @@ func HandleImageDelete(w http.ResponseWriter, r *http.Request) {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "template_id required"})
|
||||
return
|
||||
}
|
||||
if isImageDownloadActive(req.TemplateID) {
|
||||
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: "Image is downloading; cancel it before deleting"})
|
||||
return
|
||||
}
|
||||
|
||||
tmpl := lxc.FindTemplate(req.TemplateID)
|
||||
if tmpl == nil {
|
||||
@@ -300,6 +496,9 @@ func HandleImageToggle(w http.ResponseWriter, r *http.Request) {
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||
return
|
||||
}
|
||||
if !requireScope(w, r, "image:toggle") {
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
TemplateID string `json:"template_id"`
|
||||
@@ -326,6 +525,9 @@ func HandleEnabledImages(w http.ResponseWriter, r *http.Request) {
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||
return
|
||||
}
|
||||
if !requireScope(w, r, "image:read") {
|
||||
return
|
||||
}
|
||||
|
||||
runtime := runtimeFromRequest(r.URL.Query().Get("type"))
|
||||
enabledSet := getEnabledImageSet()
|
||||
@@ -336,7 +538,7 @@ func HandleEnabledImages(w http.ResponseWriter, r *http.Request) {
|
||||
if downloaded, _ := kvm.ImageDownloadedInfo(t.ID); enabledSet[t.ID] && downloaded {
|
||||
result = append(result, map[string]string{
|
||||
"id": t.ID, "name": t.Name, "distro": t.Distro, "release": t.Release, "arch": t.Arch,
|
||||
"description": t.Description, "type": config.VirtualizationKVM,
|
||||
"description": t.Description, "type": config.VirtualizationKVM, "desktop": t.Desktop,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,9 @@ func HandleIPv6Status(w http.ResponseWriter, r *http.Request) {
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||
return
|
||||
}
|
||||
if !requireScope(w, r, "ipv6:read") {
|
||||
return
|
||||
}
|
||||
status := lxcManager.DetectIPv6Status()
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: status})
|
||||
}
|
||||
|
||||
@@ -50,6 +50,9 @@ func HandleRouting(w http.ResponseWriter, r *http.Request) {
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||
return
|
||||
}
|
||||
if !requireScope(w, r, "routing:read") {
|
||||
return
|
||||
}
|
||||
|
||||
nat4Mappings := make([]nat4Route, 0)
|
||||
usedPorts := map[int]bool{}
|
||||
|
||||
@@ -72,12 +72,12 @@ func reinstallByRuntime(id int, templateID string) error {
|
||||
return lxcManager.ReinstallContainer(id, templateID)
|
||||
}
|
||||
|
||||
func resetPasswordByRuntime(id int) (string, error) {
|
||||
func resetPasswordByRuntime(id int, password string) (string, error) {
|
||||
c := config.FindContainer(id)
|
||||
if c != nil && c.IsKVM() {
|
||||
return kvmManager.ResetSSHPassword(id)
|
||||
return kvmManager.ResetSSHPassword(id, password)
|
||||
}
|
||||
return lxcManager.ResetSSHPassword(id)
|
||||
return lxcManager.ResetSSHPassword(id, password)
|
||||
}
|
||||
|
||||
func assignIPv6ByRuntime(id int) (*config.Container, error) {
|
||||
|
||||
@@ -654,18 +654,27 @@ func HandleSecurityAlerts(w http.ResponseWriter, r *http.Request) {
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||
return
|
||||
}
|
||||
if !requireScope(w, r, "security:read") {
|
||||
return
|
||||
}
|
||||
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: mergedSecurityAlerts()})
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: filterSecurityAlertsForRequest(r, mergedSecurityAlerts())})
|
||||
}
|
||||
|
||||
// HandleSecuritySettings returns or updates security automation settings.
|
||||
func HandleSecuritySettings(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
if !requireScope(w, r, "security:read") {
|
||||
return
|
||||
}
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]bool{
|
||||
"auto_shutdown": config.AppConfig.SecurityAutoShutdown,
|
||||
}})
|
||||
case http.MethodPut:
|
||||
if !requireScope(w, r, "security:settings") {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
AutoShutdown bool `json:"auto_shutdown"`
|
||||
}
|
||||
@@ -678,6 +687,7 @@ func HandleSecuritySettings(w http.ResponseWriter, r *http.Request) {
|
||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
auditRequest(r, "security.settings", "auto_shutdown", fmt.Sprintf("auto_shutdown=%v", req.AutoShutdown), true, "")
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]bool{
|
||||
"auto_shutdown": config.AppConfig.SecurityAutoShutdown,
|
||||
}})
|
||||
@@ -692,6 +702,9 @@ func HandleSecurityCheck(w http.ResponseWriter, r *http.Request) {
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||
return
|
||||
}
|
||||
if !requireScope(w, r, "security:check") {
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
ContainerName string `json:"container_name"`
|
||||
@@ -706,6 +719,10 @@ func HandleSecurityCheck(w http.ResponseWriter, r *http.Request) {
|
||||
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found or not running"})
|
||||
return
|
||||
}
|
||||
if !isContainerAllowedForRequest(r, c.UUID) {
|
||||
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Access denied to this container"})
|
||||
return
|
||||
}
|
||||
|
||||
ensureScanner().checkContainer(c.Name, c.IP)
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Security check completed"})
|
||||
@@ -717,6 +734,9 @@ func HandleSecurityLogs(w http.ResponseWriter, r *http.Request) {
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||
return
|
||||
}
|
||||
if !requireScope(w, r, "security:read") {
|
||||
return
|
||||
}
|
||||
|
||||
containerName := r.URL.Query().Get("container")
|
||||
if containerName == "" {
|
||||
@@ -729,6 +749,10 @@ func HandleSecurityLogs(w http.ResponseWriter, r *http.Request) {
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: []map[string]interface{}{}})
|
||||
return
|
||||
}
|
||||
if !isContainerAllowedForRequest(r, c.UUID) {
|
||||
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Access denied to this container"})
|
||||
return
|
||||
}
|
||||
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: getConnectionLogs(c.IP)})
|
||||
}
|
||||
@@ -781,12 +805,15 @@ func HandleContainerSecuritySummary(w http.ResponseWriter, r *http.Request) {
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||
return
|
||||
}
|
||||
if !requireScope(w, r, "security:read") {
|
||||
return
|
||||
}
|
||||
|
||||
critical := 0
|
||||
high := 0
|
||||
medium := 0
|
||||
low := 0
|
||||
alerts := mergedSecurityAlerts()
|
||||
alerts := filterSecurityAlertsForRequest(r, mergedSecurityAlerts())
|
||||
for _, a := range alerts {
|
||||
switch a.Severity {
|
||||
case "critical":
|
||||
@@ -812,6 +839,20 @@ func HandleContainerSecuritySummary(w http.ResponseWriter, r *http.Request) {
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: summary})
|
||||
}
|
||||
|
||||
func filterSecurityAlertsForRequest(r *http.Request, alerts []SecurityAlert) []SecurityAlert {
|
||||
allowed, restricted := requestAllowedContainers(r)
|
||||
if !restricted {
|
||||
return alerts
|
||||
}
|
||||
filtered := make([]SecurityAlert, 0, len(alerts))
|
||||
for _, alert := range alerts {
|
||||
if c := config.FindContainerByName(alert.ContainerName); c != nil && isContainerAllowed(allowed, c) {
|
||||
filtered = append(filtered, alert)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func mergedSecurityAlerts() []SecurityAlert {
|
||||
ss := ensureScanner()
|
||||
ss.mu.Lock()
|
||||
|
||||
@@ -56,6 +56,9 @@ func HandleLoginLogs(w http.ResponseWriter, r *http.Request) {
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||
return
|
||||
}
|
||||
if !requireScope(w, r, "loginlog:read") {
|
||||
return
|
||||
}
|
||||
|
||||
// Return in reverse (newest first)
|
||||
reversed := make([]LoginLog, len(loginLogs))
|
||||
|
||||
@@ -16,7 +16,11 @@ func HandleSnapshots(w http.ResponseWriter, r *http.Request) {
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||
return
|
||||
}
|
||||
if !requireScope(w, r, "snapshot:read") {
|
||||
return
|
||||
}
|
||||
snapshots := append([]config.Snapshot(nil), config.AppConfig.Snapshots...)
|
||||
snapshots = filterSnapshotsForRequest(r, snapshots)
|
||||
sortSnapshotsNewestFirst(snapshots)
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: snapshots})
|
||||
}
|
||||
@@ -24,17 +28,35 @@ func HandleSnapshots(w http.ResponseWriter, r *http.Request) {
|
||||
func handleContainerSnapshots(w http.ResponseWriter, r *http.Request, containerID int, action string) {
|
||||
switch {
|
||||
case action == "snapshots" && r.Method == http.MethodGet:
|
||||
if !requireScope(w, r, "snapshot:read") {
|
||||
return
|
||||
}
|
||||
listContainerSnapshots(w, r, containerID)
|
||||
case action == "snapshots" && r.Method == http.MethodPost:
|
||||
if !requireScope(w, r, "snapshot:create") {
|
||||
return
|
||||
}
|
||||
createContainerSnapshot(w, r, containerID)
|
||||
case action == "snapshots/schedule" && r.Method == http.MethodPost:
|
||||
if !requireScope(w, r, "snapshot:schedule") {
|
||||
return
|
||||
}
|
||||
updateSnapshotSchedule(w, r, containerID)
|
||||
case action == "snapshots/quota" && r.Method == http.MethodPut:
|
||||
if !requireScope(w, r, "snapshot:schedule") {
|
||||
return
|
||||
}
|
||||
updateSnapshotQuota(w, r, containerID)
|
||||
case strings.HasPrefix(action, "snapshots/") && strings.HasSuffix(action, "/restore") && r.Method == http.MethodPost:
|
||||
if !requireScope(w, r, "snapshot:restore") {
|
||||
return
|
||||
}
|
||||
snapshotID := strings.TrimSuffix(strings.TrimPrefix(action, "snapshots/"), "/restore")
|
||||
restoreContainerSnapshot(w, r, containerID, snapshotID)
|
||||
case strings.HasPrefix(action, "snapshots/") && r.Method == http.MethodDelete:
|
||||
if !requireScope(w, r, "snapshot:delete") {
|
||||
return
|
||||
}
|
||||
snapshotID := strings.TrimPrefix(action, "snapshots/")
|
||||
deleteContainerSnapshot(w, r, containerID, snapshotID)
|
||||
default:
|
||||
@@ -186,15 +208,7 @@ func restoreContainerSnapshot(w http.ResponseWriter, r *http.Request, containerI
|
||||
}
|
||||
|
||||
func requestUser(r *http.Request) string {
|
||||
if claims, ok := claimsFromRequest(r); ok {
|
||||
if subUser, _ := claims["sub_user"].(string); subUser != "" {
|
||||
return "user:" + subUser
|
||||
}
|
||||
if username, _ := claims["username"].(string); username != "" {
|
||||
return username
|
||||
}
|
||||
}
|
||||
return "admin"
|
||||
return requestActor(r)
|
||||
}
|
||||
|
||||
func sortSnapshotsNewestFirst(snapshots []config.Snapshot) {
|
||||
@@ -204,3 +218,17 @@ func sortSnapshotsNewestFirst(snapshots []config.Snapshot) {
|
||||
return tj.Before(ti)
|
||||
})
|
||||
}
|
||||
|
||||
func filterSnapshotsForRequest(r *http.Request, snapshots []config.Snapshot) []config.Snapshot {
|
||||
allowed, restricted := requestAllowedContainers(r)
|
||||
if !restricted {
|
||||
return snapshots
|
||||
}
|
||||
filtered := make([]config.Snapshot, 0, len(snapshots))
|
||||
for _, snapshot := range snapshots {
|
||||
if c := config.FindContainer(snapshot.ContainerID); c != nil && isContainerAllowed(allowed, c) {
|
||||
filtered = append(filtered, snapshot)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
@@ -42,6 +42,9 @@ func HandleWebSSHTicket(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if !requireScope(w, r, "terminal:ssh") {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
ContainerName string `json:"container_name"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,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()
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,9 @@ func HandleSubUserCreate(w http.ResponseWriter, r *http.Request) {
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||
return
|
||||
}
|
||||
if !requireScope(w, r, "subuser:create") {
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
ContainerName string `json:"container_name"`
|
||||
@@ -281,13 +284,40 @@ func subUserAllowedContainers(r *http.Request) (subUserAccess, bool) {
|
||||
return allowed, true
|
||||
}
|
||||
|
||||
func requestAllowedContainers(r *http.Request) (subUserAccess, bool) {
|
||||
if ctx, ok := authContextFromRequest(r); ok {
|
||||
if ctx.Type == authTypeAPIKey && len(ctx.ContainerUUIDs) == 0 {
|
||||
return subUserAccess{}, false
|
||||
}
|
||||
if ctx.Type == authTypeSubUser || ctx.Type == authTypeAPIKey {
|
||||
allowed := subUserAccess{names: make(map[string]bool), uuids: make(map[string]bool)}
|
||||
for _, uuid := range ctx.ContainerUUIDs {
|
||||
allowed.uuids[uuid] = true
|
||||
}
|
||||
if ctx.Type == authTypeSubUser && len(ctx.ContainerUUIDs) == 0 {
|
||||
legacy, ok := subUserAllowedContainers(r)
|
||||
if ok {
|
||||
return legacy, true
|
||||
}
|
||||
}
|
||||
return allowed, true
|
||||
}
|
||||
}
|
||||
return subUserAllowedContainers(r)
|
||||
}
|
||||
|
||||
func isAccessRestrictedRequest(r *http.Request) bool {
|
||||
_, restricted := requestAllowedContainers(r)
|
||||
return restricted
|
||||
}
|
||||
|
||||
func containerByIdentifier(identifier string) *config.Container {
|
||||
return config.FindContainerByIdentifier(identifier)
|
||||
}
|
||||
|
||||
func isContainerAllowedForRequest(r *http.Request, identifier string) bool {
|
||||
allowed, isSubUser := subUserAllowedContainers(r)
|
||||
if !isSubUser {
|
||||
allowed, restricted := requestAllowedContainers(r)
|
||||
if !restricted {
|
||||
return true
|
||||
}
|
||||
c := containerByIdentifier(identifier)
|
||||
@@ -303,6 +333,9 @@ func HandleAuditLogs(w http.ResponseWriter, r *http.Request) {
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||
return
|
||||
}
|
||||
if !requireScope(w, r, "audit:read") {
|
||||
return
|
||||
}
|
||||
|
||||
logs := config.AppConfig.AuditLogs
|
||||
if logs == nil {
|
||||
@@ -327,12 +360,20 @@ func SubUserMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
||||
}
|
||||
|
||||
path := r.URL.Path
|
||||
if path == "/api/tasks" && r.Method == http.MethodGet {
|
||||
containerPrefix := "/api/containers/"
|
||||
containerListPath := "/api/containers"
|
||||
tasksPath := "/api/tasks"
|
||||
if strings.HasPrefix(path, "/api/v1/") {
|
||||
containerPrefix = "/api/v1/containers/"
|
||||
containerListPath = "/api/v1/containers"
|
||||
tasksPath = "/api/v1/tasks"
|
||||
}
|
||||
if path == tasksPath && r.Method == http.MethodGet {
|
||||
next(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if path == "/api/containers" {
|
||||
if path == containerListPath {
|
||||
if r.Method != http.MethodGet {
|
||||
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Sub-users cannot create containers"})
|
||||
return
|
||||
@@ -341,8 +382,8 @@ func SubUserMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
if len(path) > len("/api/containers/") {
|
||||
rest := path[len("/api/containers/"):]
|
||||
if strings.HasPrefix(path, containerPrefix) {
|
||||
rest := path[len(containerPrefix):]
|
||||
parts := splitPath(rest)
|
||||
if len(parts) > 0 && parts[0] != "" {
|
||||
c := containerByIdentifier(parts[0])
|
||||
@@ -373,8 +414,8 @@ func SubUserMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
||||
}
|
||||
|
||||
func filterContainersForRequest(r *http.Request, containers []config.Container) []config.Container {
|
||||
allowed, isSubUser := subUserAllowedContainers(r)
|
||||
if !isSubUser {
|
||||
allowed, restricted := requestAllowedContainers(r)
|
||||
if !restricted {
|
||||
return containers
|
||||
}
|
||||
filtered := make([]config.Container, 0, len(containers))
|
||||
@@ -387,33 +428,47 @@ func filterContainersForRequest(r *http.Request, containers []config.Container)
|
||||
}
|
||||
|
||||
func filterTasksForRequest(r *http.Request, tasks []*Task) []*Task {
|
||||
allowed, isSubUser := subUserAllowedContainers(r)
|
||||
if !isSubUser {
|
||||
return tasks
|
||||
}
|
||||
filtered := make([]*Task, 0, len(tasks))
|
||||
for _, task := range tasks {
|
||||
if c := config.FindContainer(task.ContainerID); c != nil && isContainerAllowed(allowed, c) {
|
||||
if isTaskAllowedForRequest(r, task) {
|
||||
filtered = append(filtered, task)
|
||||
continue
|
||||
}
|
||||
if task.ContainerName != "" {
|
||||
if c := config.FindContainerByName(task.ContainerName); c != nil && isContainerAllowed(allowed, c) {
|
||||
filtered = append(filtered, task)
|
||||
continue
|
||||
}
|
||||
}
|
||||
if task.Config.Name != "" {
|
||||
if c := config.FindContainerByName(task.Config.Name); c != nil && isContainerAllowed(allowed, c) {
|
||||
filtered = append(filtered, task)
|
||||
}
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func isTaskAllowedForRequest(r *http.Request, task *Task) bool {
|
||||
allowed, restricted := requestAllowedContainers(r)
|
||||
if !restricted {
|
||||
return true
|
||||
}
|
||||
if task == nil {
|
||||
return false
|
||||
}
|
||||
if c := config.FindContainer(task.ContainerID); c != nil && isContainerAllowed(allowed, c) {
|
||||
return true
|
||||
}
|
||||
if task.ContainerName != "" {
|
||||
if c := config.FindContainerByName(task.ContainerName); c != nil && isContainerAllowed(allowed, c) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if task.Config.Name != "" {
|
||||
if c := config.FindContainerByName(task.Config.Name); c != nil && isContainerAllowed(allowed, c) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isContainerAllowed(allowed subUserAccess, c *config.Container) bool {
|
||||
return c != nil && c.UUID != "" && allowed.uuids[c.UUID]
|
||||
if c == nil {
|
||||
return false
|
||||
}
|
||||
if c.UUID != "" && allowed.uuids[c.UUID] {
|
||||
return true
|
||||
}
|
||||
return c.Name != "" && allowed.names[c.Name]
|
||||
}
|
||||
|
||||
func isSubUserBlockedAction(action string, method string) bool {
|
||||
@@ -536,6 +591,9 @@ func HandleSubUserList(w http.ResponseWriter, r *http.Request) {
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||
return
|
||||
}
|
||||
if !requireScope(w, r, "subuser:read") {
|
||||
return
|
||||
}
|
||||
|
||||
result := make([]SubUserListItem, 0, len(config.AppConfig.SubUsers))
|
||||
for _, su := range config.AppConfig.SubUsers {
|
||||
@@ -585,7 +643,8 @@ func HandleSubUserList(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// HandleSubUserAction handles actions on a specific sub-user
|
||||
func HandleSubUserAction(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api/sub-users/")
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api/v1/sub-users/")
|
||||
path = strings.TrimPrefix(path, "/api/sub-users/")
|
||||
parts := strings.SplitN(path, "/", 2)
|
||||
subUserID := parts[0]
|
||||
action := ""
|
||||
@@ -608,6 +667,9 @@ func HandleSubUserAction(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
switch {
|
||||
case action == "rotate-password" && r.Method == http.MethodPost:
|
||||
if !requireScope(w, r, "subuser:update") {
|
||||
return
|
||||
}
|
||||
password := generateRandomStr(16)
|
||||
if hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost); err == nil {
|
||||
target.PassHash = string(hash)
|
||||
@@ -625,11 +687,17 @@ func HandleSubUserAction(w http.ResponseWriter, r *http.Request) {
|
||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to generate password"})
|
||||
|
||||
case action == "audit-logs" && r.Method == http.MethodGet:
|
||||
if !requireScope(w, r, "audit:read") {
|
||||
return
|
||||
}
|
||||
// Filter audit logs for this sub-user
|
||||
logs := filterSubUserAuditLogs(target.Username)
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: logs})
|
||||
|
||||
case action == "login-logs" && r.Method == http.MethodGet:
|
||||
if !requireScope(w, r, "loginlog:read") {
|
||||
return
|
||||
}
|
||||
// Filter login logs for this sub-user
|
||||
logs := filterSubUserLoginLogs(target.Username)
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: logs})
|
||||
|
||||
@@ -11,19 +11,27 @@ import (
|
||||
)
|
||||
|
||||
type SwapInfo struct {
|
||||
TotalMB int64 `json:"total_mb"`
|
||||
UsedMB int64 `json:"used_mb"`
|
||||
FreeMB int64 `json:"free_mb"`
|
||||
Enabled bool `json:"enabled"`
|
||||
SwapFile string `json:"swap_file"`
|
||||
TotalMB int64 `json:"total_mb"`
|
||||
UsedMB int64 `json:"used_mb"`
|
||||
FreeMB int64 `json:"free_mb"`
|
||||
Enabled bool `json:"enabled"`
|
||||
SwapFile string `json:"swap_file"`
|
||||
}
|
||||
|
||||
const (
|
||||
minSwapSizeMB = 128
|
||||
maxSwapSizeMB = 262144
|
||||
)
|
||||
|
||||
// HandleSwapInfo returns current swap status
|
||||
func HandleSwapInfo(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||
return
|
||||
}
|
||||
if !requireScope(w, r, "swap:read") {
|
||||
return
|
||||
}
|
||||
|
||||
info := getSwapInfo()
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: info})
|
||||
@@ -35,9 +43,12 @@ func HandleSwapManage(w http.ResponseWriter, r *http.Request) {
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||
return
|
||||
}
|
||||
if !requireScope(w, r, "swap:manage") {
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Action string `json:"action"` // create, enable, disable, resize
|
||||
Action string `json:"action"` // create, enable, disable, resize
|
||||
SizeMB int `json:"size_mb"` // for create/resize
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
@@ -46,54 +57,63 @@ func HandleSwapManage(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
var msg string
|
||||
var err error
|
||||
|
||||
switch req.Action {
|
||||
case "create":
|
||||
if req.SizeMB <= 0 {
|
||||
req.SizeMB = 2048
|
||||
}
|
||||
err := createSwap(req.SizeMB)
|
||||
if err != nil {
|
||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
|
||||
return
|
||||
if err = validateSwapSize(req.SizeMB); err == nil {
|
||||
err = createSwap(req.SizeMB)
|
||||
}
|
||||
msg = fmt.Sprintf("已创建 %d MB SWAP", req.SizeMB)
|
||||
|
||||
case "enable":
|
||||
err := enableSwap()
|
||||
if err != nil {
|
||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
err = enableSwap()
|
||||
msg = "SWAP 已启用"
|
||||
|
||||
case "disable":
|
||||
err := disableSwap()
|
||||
if err != nil {
|
||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
err = disableSwap()
|
||||
msg = "SWAP 已禁用"
|
||||
|
||||
case "resize":
|
||||
if req.SizeMB <= 0 {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid size"})
|
||||
return
|
||||
if err = validateSwapSize(req.SizeMB); err == nil {
|
||||
err = disableSwap()
|
||||
}
|
||||
if err == nil {
|
||||
err = createSwap(req.SizeMB)
|
||||
}
|
||||
if err == nil {
|
||||
err = enableSwap()
|
||||
}
|
||||
disableSwap()
|
||||
createSwap(req.SizeMB)
|
||||
enableSwap()
|
||||
msg = fmt.Sprintf("SWAP 已调整为 %d MB", req.SizeMB)
|
||||
|
||||
default:
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid action: " + req.Action})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
auditRequest(r, "swap."+req.Action, "/swapfile", fmt.Sprintf("size_mb=%d", req.SizeMB), false, err.Error())
|
||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
info := getSwapInfo()
|
||||
auditRequest(r, "swap."+req.Action, "/swapfile", fmt.Sprintf("size_mb=%d", req.SizeMB), true, "")
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: msg, Data: info})
|
||||
}
|
||||
|
||||
func validateSwapSize(sizeMB int) error {
|
||||
if sizeMB < minSwapSizeMB {
|
||||
return fmt.Errorf("swap size must be at least %d MB", minSwapSizeMB)
|
||||
}
|
||||
if sizeMB > maxSwapSizeMB {
|
||||
return fmt.Errorf("swap size cannot exceed %d MB", maxSwapSizeMB)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func getSwapInfo() SwapInfo {
|
||||
info := SwapInfo{SwapFile: "/swapfile"}
|
||||
|
||||
@@ -160,6 +180,9 @@ func createSwap(sizeMB int) error {
|
||||
func enableSwap() error {
|
||||
swapFile := "/swapfile"
|
||||
if _, err := os.Stat(swapFile); os.IsNotExist(err) {
|
||||
if getSwapInfo().Enabled {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("swap 文件不存在,请先创建")
|
||||
}
|
||||
|
||||
@@ -180,7 +203,7 @@ func disableSwap() error {
|
||||
cmd := exec.Command("swapoff", swapFile)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
if strings.Contains(string(output), "No such") {
|
||||
if strings.Contains(string(output), "No such") || strings.Contains(string(output), "Invalid argument") {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("禁用 swap 失败: %v, %s", err, string(output))
|
||||
|
||||
@@ -122,9 +122,13 @@ func (q *TaskQueue) EnqueueBatchWithAudit(taskType TaskType, ids []int, template
|
||||
}
|
||||
|
||||
func (q *TaskQueue) EnqueueBatchCreate(configs []lxc.ContainerConfig) []string {
|
||||
return q.EnqueueBatchCreateWithAudit(configs, "admin", "", "")
|
||||
}
|
||||
|
||||
func (q *TaskQueue) EnqueueBatchCreateWithAudit(configs []lxc.ContainerConfig, user string, ip string, userAgent string) []string {
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
return q.enqueueBatchCreateList(configs)
|
||||
return q.enqueueBatchCreateList(configs, user, ip, userAgent)
|
||||
}
|
||||
|
||||
func (q *TaskQueue) ActiveCreateNames() map[string]bool {
|
||||
@@ -147,7 +151,7 @@ func (q *TaskQueue) ActiveCreateNames() map[string]bool {
|
||||
return names
|
||||
}
|
||||
|
||||
func (q *TaskQueue) enqueueBatchCreateList(configs []lxc.ContainerConfig) []string {
|
||||
func (q *TaskQueue) enqueueBatchCreateList(configs []lxc.ContainerConfig, user string, ip string, userAgent string) []string {
|
||||
var result []string
|
||||
for _, cfg := range configs {
|
||||
cfgCopy := cfg
|
||||
@@ -161,6 +165,9 @@ func (q *TaskQueue) enqueueBatchCreateList(configs []lxc.ContainerConfig) []stri
|
||||
Status: "pending",
|
||||
CreatedAt: time.Now().Format("2006-01-02 15:04:05"),
|
||||
Config: cfgCopy,
|
||||
User: user,
|
||||
IP: ip,
|
||||
UserAgent: userAgent,
|
||||
}
|
||||
q.enqueueTask(task)
|
||||
result = append(result, task.ID)
|
||||
@@ -424,6 +431,8 @@ func (q *TaskQueue) persistTasks() {
|
||||
TemplateID: t.TemplateID,
|
||||
Config: string(cfgJSON),
|
||||
User: t.User,
|
||||
IP: t.IP,
|
||||
UserAgent: t.UserAgent,
|
||||
})
|
||||
}
|
||||
config.SaveTasks(saved)
|
||||
@@ -456,13 +465,8 @@ func HandleSingleTaskAction(w http.ResponseWriter, r *http.Request, id int, acti
|
||||
name = c.Name
|
||||
}
|
||||
|
||||
// Determine user from JWT claims
|
||||
user := "admin"
|
||||
if claims, ok := claimsFromRequest(r); ok {
|
||||
if subUser, _ := claims["sub_user"].(string); subUser != "" {
|
||||
user = "user:" + subUser
|
||||
}
|
||||
}
|
||||
// Determine user from authenticated request context.
|
||||
user := requestActor(r)
|
||||
ip := clientIP(r)
|
||||
userAgent := r.Header.Get("User-Agent")
|
||||
|
||||
@@ -517,6 +521,13 @@ func HandleBatchCreate(w http.ResponseWriter, r *http.Request) {
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||
return
|
||||
}
|
||||
if !requireScope(w, r, "container:create") {
|
||||
return
|
||||
}
|
||||
if isAccessRestrictedRequest(r) {
|
||||
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Container-bound API keys cannot create containers"})
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Containers []lxc.ContainerConfig `json:"containers"`
|
||||
}
|
||||
@@ -576,7 +587,7 @@ func HandleBatchCreate(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
requestNames[name] = true
|
||||
}
|
||||
ids := globalQueue.EnqueueBatchCreate(req.Containers)
|
||||
ids := globalQueue.EnqueueBatchCreateWithAudit(req.Containers, requestActor(r), clientIP(r), r.UserAgent())
|
||||
jsonResponse(w, http.StatusAccepted, APIResponse{Success: true, Data: ids})
|
||||
}
|
||||
|
||||
@@ -586,6 +597,10 @@ func HandleBatchAction(w http.ResponseWriter, r *http.Request) {
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||
return
|
||||
}
|
||||
if !hasAnyScope(r, "container:power", "container:delete", "container:reinstall") {
|
||||
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Insufficient API key scope"})
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Action string `json:"action"`
|
||||
Containers []int `json:"containers"`
|
||||
@@ -597,21 +612,47 @@ func HandleBatchAction(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
var taskType TaskType
|
||||
var requiredScope string
|
||||
switch req.Action {
|
||||
case "start":
|
||||
taskType = TaskStart
|
||||
requiredScope = "container:power"
|
||||
case "stop":
|
||||
taskType = TaskStop
|
||||
requiredScope = "container:power"
|
||||
case "restart":
|
||||
taskType = TaskRestart
|
||||
requiredScope = "container:power"
|
||||
case "delete":
|
||||
taskType = TaskDelete
|
||||
requiredScope = "container:delete"
|
||||
case "reinstall":
|
||||
if req.TemplateID == "" {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "template_id required"})
|
||||
return
|
||||
}
|
||||
if !isTemplateEnabledAndDownloaded(req.TemplateID) {
|
||||
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Template is not enabled or downloaded"})
|
||||
return
|
||||
}
|
||||
taskType = TaskReinstall
|
||||
requiredScope = "container:reinstall"
|
||||
default:
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Unknown action"})
|
||||
return
|
||||
}
|
||||
if !requireScope(w, r, requiredScope) {
|
||||
return
|
||||
}
|
||||
for _, id := range req.Containers {
|
||||
c := config.FindContainer(id)
|
||||
if c == nil || !isContainerAllowedForRequest(r, c.UUID) {
|
||||
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Access denied to one or more containers"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
ids := globalQueue.EnqueueBatch(taskType, req.Containers, req.TemplateID)
|
||||
ids := globalQueue.EnqueueBatchWithAudit(taskType, req.Containers, req.TemplateID, requestActor(r), clientIP(r), r.UserAgent())
|
||||
jsonResponse(w, http.StatusAccepted, APIResponse{Success: true, Data: ids})
|
||||
}
|
||||
|
||||
@@ -621,13 +662,22 @@ func HandleTaskDelete(w http.ResponseWriter, r *http.Request) {
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||
return
|
||||
}
|
||||
// URL: /api/tasks/{id}
|
||||
taskID := strings.TrimPrefix(r.URL.Path, "/api/tasks/")
|
||||
if !requireScope(w, r, "task:delete") {
|
||||
return
|
||||
}
|
||||
// URL: /api/tasks/{id} or /api/v1/tasks/{id}
|
||||
taskID := strings.TrimPrefix(r.URL.Path, "/api/v1/tasks/")
|
||||
taskID = strings.TrimPrefix(taskID, "/api/tasks/")
|
||||
if taskID == "" {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Task ID required"})
|
||||
return
|
||||
}
|
||||
globalQueue.mu.Lock()
|
||||
if task := globalQueue.tasks[taskID]; task != nil && !isTaskAllowedForRequest(r, task) {
|
||||
globalQueue.mu.Unlock()
|
||||
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Access denied to this task"})
|
||||
return
|
||||
}
|
||||
delete(globalQueue.tasks, taskID)
|
||||
// Also remove from both queues if pending
|
||||
newCreate := make([]*Task, 0, len(globalQueue.createQueue))
|
||||
@@ -655,6 +705,9 @@ func HandleTasks(w http.ResponseWriter, r *http.Request) {
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||
return
|
||||
}
|
||||
if !requireScope(w, r, "task:read") {
|
||||
return
|
||||
}
|
||||
tasks := globalQueue.GetTasks()
|
||||
tasks = filterTasksForRequest(r, tasks)
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: tasks})
|
||||
@@ -691,6 +744,8 @@ func RestoreTasks() {
|
||||
TemplateID: st.TemplateID,
|
||||
Config: cfg,
|
||||
User: st.User,
|
||||
IP: st.IP,
|
||||
UserAgent: st.UserAgent,
|
||||
}
|
||||
if st.Status == "pending" || st.Status == "running" {
|
||||
// Reset running tasks back to pending so they get retried
|
||||
|
||||
@@ -18,7 +18,10 @@ import (
|
||||
type webVNCTicket struct {
|
||||
ContainerName string
|
||||
ContainerUUID string
|
||||
Username string
|
||||
SubUser bool
|
||||
ClientIP string
|
||||
UserAgent string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
@@ -33,6 +36,9 @@ func HandleVNCTicket(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if !requireScope(w, r, "terminal:vnc") {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
ContainerName string `json:"container_name"`
|
||||
}
|
||||
@@ -58,13 +64,17 @@ func HandleVNCTicket(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
username, isSubUser := vncRequesterIdentity(r)
|
||||
ticket := randomHex(32)
|
||||
webVNCTickets.Lock()
|
||||
cleanupExpiredWebVNCTicketsLocked(time.Now())
|
||||
webVNCTickets.items[ticket] = webVNCTicket{
|
||||
ContainerName: c.Name,
|
||||
ContainerUUID: c.UUID,
|
||||
SubUser: isSubUserRequest(r),
|
||||
Username: username,
|
||||
SubUser: isSubUser,
|
||||
ClientIP: clientIP(r),
|
||||
UserAgent: r.UserAgent(),
|
||||
ExpiresAt: time.Now().Add(60 * time.Second),
|
||||
}
|
||||
webVNCTickets.Unlock()
|
||||
@@ -89,7 +99,7 @@ func HandleVNCProxy(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
item, ok := consumeWebVNCTicket(ticket, containerName)
|
||||
item, ok := consumeWebVNCTicket(ticket, containerName, r)
|
||||
if !ok {
|
||||
http.Error(w, "invalid or expired ticket", http.StatusUnauthorized)
|
||||
return
|
||||
@@ -137,7 +147,7 @@ func HandleVNCProxy(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
defer ws.Close()
|
||||
|
||||
log.Printf("WebVNC connected for container %s -> 127.0.0.1:%d", containerName, vncPort)
|
||||
log.Printf("WebVNC connected for container %s as %s (sub_user=%t) -> 127.0.0.1:%d", containerName, item.Username, item.SubUser, vncPort)
|
||||
|
||||
done := make(chan string, 2)
|
||||
var writeMu sync.Mutex
|
||||
@@ -147,7 +157,31 @@ func HandleVNCProxy(w http.ResponseWriter, r *http.Request) {
|
||||
reason := <-done
|
||||
_ = vncConn.Close()
|
||||
_ = ws.Close()
|
||||
log.Printf("WebVNC disconnected for container %s: %s", containerName, reason)
|
||||
log.Printf("WebVNC disconnected for container %s as %s: %s", containerName, item.Username, reason)
|
||||
}
|
||||
|
||||
func vncRequesterIdentity(r *http.Request) (string, bool) {
|
||||
if ctx, ok := authContextFromRequest(r); ok {
|
||||
switch ctx.Type {
|
||||
case authTypeSubUser:
|
||||
return ctx.Username, true
|
||||
case authTypeAPIKey:
|
||||
return ctx.Actor, false
|
||||
case authTypeAdmin:
|
||||
return ctx.Username, false
|
||||
}
|
||||
}
|
||||
claims, ok := claimsFromRequest(r)
|
||||
if !ok {
|
||||
return "api-key", false
|
||||
}
|
||||
if subUser, ok := claims["sub_user"].(string); ok && subUser != "" {
|
||||
return subUser, true
|
||||
}
|
||||
if username, ok := claims["username"].(string); ok && username != "" {
|
||||
return username, false
|
||||
}
|
||||
return "unknown", false
|
||||
}
|
||||
|
||||
func webVNCTicketFromRequest(r *http.Request) string {
|
||||
@@ -175,7 +209,7 @@ func webVNCResponseProtocol(r *http.Request) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func consumeWebVNCTicket(ticket, containerName string) (webVNCTicket, bool) {
|
||||
func consumeWebVNCTicket(ticket, containerName string, r *http.Request) (webVNCTicket, bool) {
|
||||
now := time.Now()
|
||||
webVNCTickets.Lock()
|
||||
defer webVNCTickets.Unlock()
|
||||
@@ -185,7 +219,10 @@ func consumeWebVNCTicket(ticket, containerName string) (webVNCTicket, bool) {
|
||||
return webVNCTicket{}, false
|
||||
}
|
||||
delete(webVNCTickets.items, ticket)
|
||||
return item, item.ContainerName == containerName && now.Before(item.ExpiresAt)
|
||||
return item, item.ContainerName == containerName &&
|
||||
item.ClientIP == clientIP(r) &&
|
||||
item.UserAgent == r.UserAgent() &&
|
||||
now.Before(item.ExpiresAt)
|
||||
}
|
||||
|
||||
func cleanupExpiredWebVNCTicketsLocked(now time.Time) {
|
||||
|
||||
+245
-11
@@ -20,6 +20,12 @@ import (
|
||||
|
||||
var manager = lxc.NewManager()
|
||||
|
||||
const (
|
||||
clicdBackupDir = "/root/clicd-backups"
|
||||
clicdNewBinaryPath = "/usr/local/bin/clicd.new"
|
||||
libvirtDefaultNetworkMarker = "/var/lib/clicd/kvm/default-network.created"
|
||||
)
|
||||
|
||||
// Run starts the CLI interface.
|
||||
func Run() {
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
@@ -198,11 +204,18 @@ func cliCreateContainer(reader *bufio.Reader) {
|
||||
container := config.FindContainerByName(name)
|
||||
fmt.Printf("容器 %s 创建成功\n", name)
|
||||
if container != nil {
|
||||
fmt.Printf("SSH: root / %s, port %d -> 22\n", container.SSHPassword, container.SSHPort)
|
||||
fmt.Print(formatSSHAccess(container.SSHPort))
|
||||
}
|
||||
restartWebPanelForConfigChange()
|
||||
}
|
||||
|
||||
func formatSSHAccess(sshPort int) string {
|
||||
if sshPort <= 0 {
|
||||
return "SSH: root, 端口未分配。密码已保存,请在 Web 面板中查看或重置。\n"
|
||||
}
|
||||
return fmt.Sprintf("SSH: root, port %d -> 22。密码已保存,请在 Web 面板中查看或重置。\n", sshPort)
|
||||
}
|
||||
|
||||
func cliStartContainer(reader *bufio.Reader) {
|
||||
id, name := selectContainer(reader, "开机")
|
||||
if id == 0 {
|
||||
@@ -532,13 +545,14 @@ func upgradeFromReleaseAsset(assetURL, latest string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
backupDir := "/root/clicd-backups"
|
||||
backupDir := clicdBackupDir
|
||||
if err := os.MkdirAll(backupDir, 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
backupPath := filepath.Join(backupDir, fmt.Sprintf("clicd.%s.%s", strings.TrimPrefix(latest, "v"), time.Now().Format("20060102-150405")))
|
||||
backupName := fmt.Sprintf("clicd.%s.%s", safeReleaseBackupComponent(latest), time.Now().Format("20060102-150405"))
|
||||
if _, err := os.Stat("/usr/local/bin/clicd"); err == nil {
|
||||
if err := copyFile("/usr/local/bin/clicd", backupPath, 0755); err != nil {
|
||||
backupPath, err := copyFileToBackup("/usr/local/bin/clicd", backupName, 0755)
|
||||
if err != nil {
|
||||
return fmt.Errorf("备份旧二进制失败: %w", err)
|
||||
}
|
||||
fmt.Printf("旧版本已备份: %s\n", backupPath)
|
||||
@@ -548,8 +562,8 @@ func upgradeFromReleaseAsset(assetURL, latest string) error {
|
||||
if err := stopService("clicd"); err != nil {
|
||||
fmt.Printf("停止 Web 服务失败,继续尝试替换: %v\n", err)
|
||||
}
|
||||
tmpBin := "/usr/local/bin/clicd.new"
|
||||
if err := copyFile(newBinary, tmpBin, 0755); err != nil {
|
||||
tmpBin := clicdNewBinaryPath
|
||||
if err := copyFileToUpgradeTemp(newBinary, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmpBin, "/usr/local/bin/clicd"); err != nil {
|
||||
@@ -614,25 +628,69 @@ func findFile(root, name string) (string, error) {
|
||||
return found, nil
|
||||
}
|
||||
|
||||
func copyFile(src, dst string, mode os.FileMode) error {
|
||||
func copyFileToBackup(src, fileName string, mode os.FileMode) (string, error) {
|
||||
if fileName == "" || strings.Contains(fileName, "/") || strings.Contains(fileName, "\\") || strings.Contains(fileName, "..") {
|
||||
return "", fmt.Errorf("unsafe backup file name: %s", fileName)
|
||||
}
|
||||
dst := filepath.Join(clicdBackupDir, fileName)
|
||||
out, err := os.OpenFile(dst, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, mode)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := copyIntoOpenFile(src, out, mode); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return dst, nil
|
||||
}
|
||||
|
||||
func copyFileToUpgradeTemp(src string, mode os.FileMode) error {
|
||||
out, err := os.OpenFile(clicdNewBinaryPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, mode)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return copyIntoOpenFile(src, out, mode)
|
||||
}
|
||||
|
||||
func copyIntoOpenFile(src string, out *os.File, mode os.FileMode) error {
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
out.Close()
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
|
||||
out, err := os.OpenFile(dst, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, mode)
|
||||
if err != nil {
|
||||
if _, err := io.Copy(out, in); err != nil {
|
||||
out.Close()
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(out, in); err != nil {
|
||||
if err := out.Chmod(mode); err != nil {
|
||||
out.Close()
|
||||
return err
|
||||
}
|
||||
if err := out.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Chmod(dst, mode)
|
||||
return nil
|
||||
}
|
||||
|
||||
func safeReleaseBackupComponent(tag string) string {
|
||||
tag = strings.TrimPrefix(strings.TrimSpace(tag), "v")
|
||||
var b strings.Builder
|
||||
for _, r := range tag {
|
||||
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '.' || r == '_' || r == '-' {
|
||||
b.WriteRune(r)
|
||||
continue
|
||||
}
|
||||
b.WriteByte('_')
|
||||
}
|
||||
component := strings.Trim(b.String(), "._-")
|
||||
if component == "" {
|
||||
return "unknown"
|
||||
}
|
||||
if len(component) > 64 {
|
||||
return component[:64]
|
||||
}
|
||||
return component
|
||||
}
|
||||
|
||||
func sameVersion(current, latest string) bool {
|
||||
@@ -697,6 +755,7 @@ func cliUninstall(reader *bufio.Reader) {
|
||||
|
||||
destroyAllLXCContainers()
|
||||
destroyAllKVMDomains()
|
||||
removeCLICDLibvirtDefaultNetwork()
|
||||
cleanupCLICDNetworking()
|
||||
removeCLICDHostHooks()
|
||||
removeCLICDQuotaRecords()
|
||||
@@ -783,8 +842,60 @@ func removeKVMDomain(name string) {
|
||||
runQuiet("virsh", "undefine", name)
|
||||
}
|
||||
|
||||
func removeCLICDLibvirtDefaultNetwork() {
|
||||
if !commandExists("virsh") || !fileExists(libvirtDefaultNetworkMarker) {
|
||||
return
|
||||
}
|
||||
if libvirtDefaultUsedByNonCLICDDomain() {
|
||||
fmt.Println("检测到非 CLICD 虚拟机仍在使用 libvirt default 网络,已保留 default/virbr0。")
|
||||
return
|
||||
}
|
||||
fmt.Println("Removing CLICD-created libvirt default network...")
|
||||
runQuiet("virsh", "net-destroy", "default")
|
||||
runQuiet("virsh", "net-undefine", "default")
|
||||
removePath(libvirtDefaultNetworkMarker)
|
||||
}
|
||||
|
||||
func libvirtDefaultUsedByNonCLICDDomain() bool {
|
||||
if !commandExists("virsh") {
|
||||
return false
|
||||
}
|
||||
out, err := exec.Command("virsh", "list", "--all", "--name").Output()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, line := range strings.Split(string(out), "\n") {
|
||||
name := strings.TrimSpace(line)
|
||||
if name == "" || isCLICDKVMDomain(name) {
|
||||
continue
|
||||
}
|
||||
if usesLibvirtDefaultNetwork(name) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func usesLibvirtDefaultNetwork(domain string) bool {
|
||||
out, err := exec.Command("virsh", "domiflist", domain).Output()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, line := range strings.Split(string(out), "\n") {
|
||||
fields := strings.Fields(line)
|
||||
for _, field := range fields {
|
||||
if field == "default" || field == "virbr0" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func cleanupCLICDNetworking() {
|
||||
removeCLICDNATRules()
|
||||
cleanupCLICDIPv6Runtime()
|
||||
cleanupCLICDIPv6BridgeRoutes()
|
||||
for _, bridge := range []string{"lxcbr0", "virbr0"} {
|
||||
deleteFilterRule("FORWARD", "-i", bridge, "-j", "ACCEPT")
|
||||
deleteFilterRule("FORWARD", "-o", bridge, "-j", "ACCEPT")
|
||||
@@ -793,6 +904,123 @@ func cleanupCLICDNetworking() {
|
||||
}
|
||||
}
|
||||
|
||||
func cleanupCLICDIPv6Runtime() {
|
||||
if config.AppConfig == nil {
|
||||
return
|
||||
}
|
||||
for _, c := range config.AppConfig.Containers {
|
||||
cleanupCLICDContainerIPv6(c)
|
||||
}
|
||||
}
|
||||
|
||||
func cleanupCLICDContainerIPv6(c config.Container) {
|
||||
bridge := "lxcbr0"
|
||||
if c.IsKVM() {
|
||||
bridge = "virbr0"
|
||||
}
|
||||
mac := strings.ToLower(strings.TrimSpace(c.MACAddress))
|
||||
if mac != "" && bridge == "virbr0" {
|
||||
deleteIP6FilterRule("FORWARD", "-i", bridge, "-m", "mac", "--mac-source", mac, "-j", "DROP")
|
||||
}
|
||||
if strings.TrimSpace(c.IPv6) == "" {
|
||||
return
|
||||
}
|
||||
|
||||
addr := strings.TrimSpace(c.IPv6)
|
||||
if slash := strings.Index(addr, "/"); slash >= 0 {
|
||||
addr = addr[:slash]
|
||||
}
|
||||
source := strings.TrimSpace(c.IPv6)
|
||||
if !strings.Contains(source, "/") {
|
||||
source += "/128"
|
||||
}
|
||||
|
||||
deleteIP6NATSource(source)
|
||||
deleteIP6FilterRule("FORWARD", "-i", bridge, "-s", source, "-j", "ACCEPT")
|
||||
deleteIP6FilterRule("FORWARD", "-o", bridge, "-d", source, "-j", "ACCEPT")
|
||||
if mac != "" && bridge == "virbr0" {
|
||||
deleteIP6FilterRule("FORWARD", "-i", bridge, "-m", "mac", "--mac-source", mac, "-s", source, "-j", "ACCEPT")
|
||||
deleteIP6FilterRule("FORWARD", "-i", bridge, "-m", "mac", "--mac-source", mac, "-j", "DROP")
|
||||
}
|
||||
|
||||
runQuiet("ip", "-6", "route", "del", source, "dev", bridge)
|
||||
if strings.TrimSpace(c.IPv6Interface) != "" {
|
||||
runQuiet("ip", "-6", "neigh", "del", "proxy", addr, "dev", c.IPv6Interface)
|
||||
}
|
||||
}
|
||||
|
||||
func cleanupCLICDIPv6BridgeRoutes() {
|
||||
if !commandExists("ip") {
|
||||
return
|
||||
}
|
||||
for _, bridge := range []string{"lxcbr0", "virbr0"} {
|
||||
out, err := exec.Command("ip", "-6", "route", "show", "dev", bridge).Output()
|
||||
if err == nil {
|
||||
for _, line := range strings.Split(string(out), "\n") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) == 0 || !strings.HasSuffix(fields[0], "/128") {
|
||||
continue
|
||||
}
|
||||
source := fields[0]
|
||||
addr := strings.TrimSuffix(source, "/128")
|
||||
deleteIP6NATSource(source)
|
||||
deleteIP6FilterRule("FORWARD", "-i", bridge, "-s", source, "-j", "ACCEPT")
|
||||
deleteIP6FilterRule("FORWARD", "-o", bridge, "-d", source, "-j", "ACCEPT")
|
||||
removeProxyNDPForAddress(addr)
|
||||
runQuiet("ip", "-6", "route", "del", source, "dev", bridge)
|
||||
}
|
||||
}
|
||||
runQuiet("ip", "-6", "addr", "del", "fe80::1/64", "dev", bridge)
|
||||
}
|
||||
}
|
||||
|
||||
func removeProxyNDPForAddress(addr string) {
|
||||
out, err := exec.Command("ip", "-6", "neigh", "show", "proxy").Output()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, line := range strings.Split(string(out), "\n") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) == 0 || fields[0] != addr {
|
||||
continue
|
||||
}
|
||||
for i := 0; i+1 < len(fields); i++ {
|
||||
if fields[i] == "dev" {
|
||||
runQuiet("ip", "-6", "neigh", "del", "proxy", addr, "dev", fields[i+1])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func deleteIP6NATSource(source string) {
|
||||
if !commandExists("ip6tables") || strings.TrimSpace(source) == "" {
|
||||
return
|
||||
}
|
||||
for {
|
||||
out, err := exec.Command("ip6tables", "-t", "nat", "-S", "POSTROUTING").Output()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
deleted := false
|
||||
for _, line := range strings.Split(string(out), "\n") {
|
||||
if !strings.Contains(line, "-s "+source) || !strings.Contains(line, " -j MASQUERADE") {
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) == 0 || fields[0] != "-A" {
|
||||
continue
|
||||
}
|
||||
fields[0] = "-D"
|
||||
args := append([]string{"-t", "nat"}, fields...)
|
||||
deleted = runCommandOK("ip6tables", args...)
|
||||
break
|
||||
}
|
||||
if !deleted {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func removeCLICDNATRules() {
|
||||
if commandExists("iptables") {
|
||||
for {
|
||||
@@ -822,6 +1050,12 @@ func deleteFilterRule(args ...string) {
|
||||
}
|
||||
}
|
||||
|
||||
func deleteIP6FilterRule(args ...string) {
|
||||
fullArgs := append([]string{"-D"}, args...)
|
||||
for runCommandOK("ip6tables", fullArgs...) {
|
||||
}
|
||||
}
|
||||
|
||||
func deleteIP6TablesBridgeRules(bridge string) {
|
||||
if !commandExists("ip6tables") {
|
||||
return
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSafeReleaseBackupComponent(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
"v1.2.3": "1.2.3",
|
||||
" release/candidate ": "release_candidate",
|
||||
"../../etc/passwd": "etc_passwd",
|
||||
"": "unknown",
|
||||
}
|
||||
for input, want := range tests {
|
||||
if got := safeReleaseBackupComponent(input); got != want {
|
||||
t.Fatalf("safeReleaseBackupComponent(%q) = %q, want %q", input, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyFileToBackupRejectsUnsafeFileName(t *testing.T) {
|
||||
unsafeNames := []string{
|
||||
"../clicd",
|
||||
"..\\clicd",
|
||||
"subdir/clicd",
|
||||
"",
|
||||
}
|
||||
for _, name := range unsafeNames {
|
||||
if _, err := copyFileToBackup("missing-source", name, 0755); err == nil || !strings.Contains(err.Error(), "unsafe backup file name") {
|
||||
t.Fatalf("copyFileToBackup(%q) error = %v, want unsafe backup file name", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatSSHAccessDoesNotExposePassword(t *testing.T) {
|
||||
out := formatSSHAccess(2222)
|
||||
if strings.Contains(out, "/") {
|
||||
t.Fatalf("formatSSHAccess output contains credential separator: %q", out)
|
||||
}
|
||||
if strings.Contains(strings.ToLower(out), "password123") {
|
||||
t.Fatalf("formatSSHAccess output exposed password: %q", out)
|
||||
}
|
||||
if !strings.Contains(out, "2222 -> 22") {
|
||||
t.Fatalf("formatSSHAccess output = %q, want SSH port mapping", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatSSHAccessHandlesMissingPort(t *testing.T) {
|
||||
out := formatSSHAccess(0)
|
||||
if !strings.Contains(out, "端口未分配") {
|
||||
t.Fatalf("formatSSHAccess output = %q, want missing port message", out)
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,8 @@ type SavedTask struct {
|
||||
TemplateID string `json:"template_id,omitempty"`
|
||||
Config string `json:"config,omitempty"`
|
||||
User string `json:"user,omitempty"`
|
||||
IP string `json:"ip,omitempty"`
|
||||
UserAgent string `json:"user_agent,omitempty"`
|
||||
}
|
||||
|
||||
// SavedLoginLog for persisting login logs
|
||||
@@ -152,13 +154,18 @@ func (c *Container) VirshName() string {
|
||||
|
||||
// SubUser represents a sub-user with access to specific containers
|
||||
type ApiKeyConfig struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
KeyHash string `json:"key_hash"`
|
||||
Prefix string `json:"prefix"`
|
||||
IPWhitelist string `json:"ip_whitelist"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
LastUsed string `json:"last_used"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
KeyHash string `json:"key_hash"`
|
||||
Prefix string `json:"prefix"`
|
||||
IPWhitelist string `json:"ip_whitelist"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
LastUsed string `json:"last_used"`
|
||||
Scopes []string `json:"scopes,omitempty"`
|
||||
ExpiresAt string `json:"expires_at,omitempty"`
|
||||
Disabled bool `json:"disabled,omitempty"`
|
||||
ContainerUUIDs []string `json:"container_uuids,omitempty"`
|
||||
LastUsedIP string `json:"last_used_ip,omitempty"`
|
||||
}
|
||||
|
||||
// DeleteApiKey removes an API key by ID
|
||||
@@ -198,26 +205,46 @@ type Snapshot struct {
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
}
|
||||
|
||||
const (
|
||||
SSLModeDisabled = "disabled"
|
||||
SSLModeLetsEncrypt = "letsencrypt"
|
||||
SSLModeSelfSigned = "self_signed"
|
||||
SSLModeUploaded = "uploaded"
|
||||
)
|
||||
|
||||
type SSLConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Mode string `json:"mode"`
|
||||
Target string `json:"target"`
|
||||
Email string `json:"email,omitempty"`
|
||||
CertPath string `json:"cert_path,omitempty"`
|
||||
KeyPath string `json:"key_path,omitempty"`
|
||||
LastIssuedAt string `json:"last_issued_at,omitempty"`
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
}
|
||||
|
||||
// ClicdConfig is the main configuration structure
|
||||
type ClicdConfig struct {
|
||||
AdminUser string `json:"admin_user"`
|
||||
AdminPassHash string `json:"admin_pass_hash"`
|
||||
JWTSecret string `json:"jwt_secret"`
|
||||
Port int `json:"port"`
|
||||
DataDir string `json:"data_dir"`
|
||||
Containers []Container `json:"containers"`
|
||||
NextContainerID int `json:"next_container_id"`
|
||||
NextVNCPort int `json:"next_vnc_port"`
|
||||
NextSSHPort int `json:"next_ssh_port"`
|
||||
SetupComplete bool `json:"setup_complete"`
|
||||
SubUsers []SubUser `json:"sub_users"`
|
||||
ApiKeys []ApiKeyConfig `json:"api_keys"`
|
||||
AuditLogs []AuditLog `json:"audit_logs"`
|
||||
Tasks []SavedTask `json:"tasks"`
|
||||
LoginLogs []SavedLoginLog `json:"login_logs"`
|
||||
EnabledImages []string `json:"enabled_images"`
|
||||
Snapshots []Snapshot `json:"snapshots"`
|
||||
SecurityAutoShutdown bool `json:"security_auto_shutdown"`
|
||||
AdminUser string `json:"admin_user"`
|
||||
AdminPassHash string `json:"admin_pass_hash"`
|
||||
JWTSecret string `json:"jwt_secret"`
|
||||
Port int `json:"port"`
|
||||
DataDir string `json:"data_dir"`
|
||||
Containers []Container `json:"containers"`
|
||||
NextContainerID int `json:"next_container_id"`
|
||||
NextVNCPort int `json:"next_vnc_port"`
|
||||
NextSSHPort int `json:"next_ssh_port"`
|
||||
SetupComplete bool `json:"setup_complete"`
|
||||
SubUsers []SubUser `json:"sub_users"`
|
||||
ApiKeys []ApiKeyConfig `json:"api_keys"`
|
||||
AuditLogs []AuditLog `json:"audit_logs"`
|
||||
Tasks []SavedTask `json:"tasks"`
|
||||
LoginLogs []SavedLoginLog `json:"login_logs"`
|
||||
EnabledImages []string `json:"enabled_images"`
|
||||
Snapshots []Snapshot `json:"snapshots"`
|
||||
SecurityAutoShutdown bool `json:"security_auto_shutdown"`
|
||||
SSL SSLConfig `json:"ssl"`
|
||||
SSLCertificates map[string]SSLConfig `json:"ssl_certificates"`
|
||||
}
|
||||
|
||||
var configPath string
|
||||
@@ -295,8 +322,11 @@ func InitConfig() (*ClicdConfig, error) {
|
||||
}
|
||||
if ok {
|
||||
AppConfig = cfg
|
||||
normalizeConfigDefaults(dataDir)
|
||||
changed := normalizeConfigDefaults(dataDir)
|
||||
if migrateLoadedConfig() {
|
||||
changed = true
|
||||
}
|
||||
if changed {
|
||||
if err := SaveConfig(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -311,9 +341,8 @@ func InitConfig() (*ClicdConfig, error) {
|
||||
if ok {
|
||||
AppConfig = legacy
|
||||
normalizeConfigDefaults(dataDir)
|
||||
if migrateLoadedConfig() {
|
||||
// Save below persists normalized legacy data into SQLite.
|
||||
}
|
||||
migrateLoadedConfig()
|
||||
// Always save legacy JSON data into SQLite.
|
||||
if err := SaveConfig(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -364,45 +393,127 @@ func InitConfig() (*ClicdConfig, error) {
|
||||
return AppConfig, nil
|
||||
}
|
||||
|
||||
func normalizeConfigDefaults(dataDir string) {
|
||||
func normalizeConfigDefaults(dataDir string) bool {
|
||||
changed := false
|
||||
if AppConfig.Port == 0 {
|
||||
AppConfig.Port = 8999
|
||||
changed = true
|
||||
}
|
||||
if AppConfig.NextVNCPort == 0 {
|
||||
AppConfig.NextVNCPort = 5900
|
||||
changed = true
|
||||
}
|
||||
if AppConfig.NextSSHPort == 0 {
|
||||
AppConfig.NextSSHPort = 22000
|
||||
changed = true
|
||||
}
|
||||
if AppConfig.NextContainerID == 0 {
|
||||
AppConfig.NextContainerID = 1
|
||||
changed = true
|
||||
}
|
||||
if AppConfig.DataDir == "" {
|
||||
AppConfig.DataDir = dataDir
|
||||
changed = true
|
||||
}
|
||||
if AppConfig.Containers == nil {
|
||||
AppConfig.Containers = make([]Container, 0)
|
||||
changed = true
|
||||
}
|
||||
if AppConfig.Snapshots == nil {
|
||||
AppConfig.Snapshots = make([]Snapshot, 0)
|
||||
changed = true
|
||||
}
|
||||
if AppConfig.SubUsers == nil {
|
||||
AppConfig.SubUsers = make([]SubUser, 0)
|
||||
changed = true
|
||||
}
|
||||
if AppConfig.ApiKeys == nil {
|
||||
AppConfig.ApiKeys = make([]ApiKeyConfig, 0)
|
||||
changed = true
|
||||
} else {
|
||||
for i := range AppConfig.ApiKeys {
|
||||
if len(AppConfig.ApiKeys[i].Scopes) == 0 {
|
||||
AppConfig.ApiKeys[i].Scopes = []string{"*"}
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if AppConfig.AuditLogs == nil {
|
||||
AppConfig.AuditLogs = make([]AuditLog, 0)
|
||||
changed = true
|
||||
}
|
||||
if AppConfig.Tasks == nil {
|
||||
AppConfig.Tasks = make([]SavedTask, 0)
|
||||
changed = true
|
||||
}
|
||||
if AppConfig.LoginLogs == nil {
|
||||
AppConfig.LoginLogs = make([]SavedLoginLog, 0)
|
||||
changed = true
|
||||
}
|
||||
if AppConfig.EnabledImages == nil {
|
||||
AppConfig.EnabledImages = make([]string, 0)
|
||||
changed = true
|
||||
}
|
||||
if normalizeSSLDefaults() {
|
||||
changed = true
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
func normalizeSSLDefaults() bool {
|
||||
changed := false
|
||||
previousMode := AppConfig.SSL.Mode
|
||||
AppConfig.SSL.Mode = NormalizeSSLMode(AppConfig.SSL.Mode)
|
||||
if AppConfig.SSL.Mode != previousMode {
|
||||
changed = true
|
||||
}
|
||||
if AppConfig.SSL.Mode == SSLModeDisabled {
|
||||
if AppConfig.SSL.Enabled {
|
||||
changed = true
|
||||
}
|
||||
AppConfig.SSL.Enabled = false
|
||||
}
|
||||
if AppConfig.SSLCertificates == nil {
|
||||
AppConfig.SSLCertificates = map[string]SSLConfig{}
|
||||
changed = true
|
||||
}
|
||||
for mode, cert := range AppConfig.SSLCertificates {
|
||||
cert.Mode = NormalizeSSLMode(cert.Mode)
|
||||
if cert.Mode == SSLModeDisabled {
|
||||
delete(AppConfig.SSLCertificates, mode)
|
||||
changed = true
|
||||
continue
|
||||
}
|
||||
if AppConfig.SSLCertificates[cert.Mode] != cert {
|
||||
changed = true
|
||||
}
|
||||
AppConfig.SSLCertificates[cert.Mode] = cert
|
||||
if mode != cert.Mode {
|
||||
delete(AppConfig.SSLCertificates, mode)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if AppConfig.SSL.Mode != SSLModeDisabled && AppConfig.SSL.CertPath != "" && AppConfig.SSL.KeyPath != "" {
|
||||
cert := AppConfig.SSL
|
||||
cert.Enabled = false
|
||||
if AppConfig.SSLCertificates[cert.Mode] != cert {
|
||||
changed = true
|
||||
}
|
||||
AppConfig.SSLCertificates[cert.Mode] = cert
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
func NormalizeSSLMode(value string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case SSLModeLetsEncrypt:
|
||||
return SSLModeLetsEncrypt
|
||||
case SSLModeSelfSigned:
|
||||
return SSLModeSelfSigned
|
||||
case SSLModeUploaded:
|
||||
return SSLModeUploaded
|
||||
default:
|
||||
return SSLModeDisabled
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -57,6 +57,28 @@ func encodeSavedTaskConfig(cfg savedTaskConfig) string {
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func encodeStringSlice(values []string) string {
|
||||
if len(values) == 0 {
|
||||
return ""
|
||||
}
|
||||
data, err := json.Marshal(values)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func decodeStringSlice(raw string) []string {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return nil
|
||||
}
|
||||
var values []string
|
||||
if err := json.Unmarshal([]byte(raw), &values); err != nil {
|
||||
return nil
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func getDBPath() string {
|
||||
cfgPath := getConfigPath()
|
||||
ext := filepath.Ext(cfgPath)
|
||||
@@ -185,7 +207,12 @@ func ensureSchema() error {
|
||||
prefix TEXT,
|
||||
ip_whitelist TEXT,
|
||||
created_at TEXT,
|
||||
last_used TEXT
|
||||
last_used TEXT,
|
||||
scopes TEXT,
|
||||
expires_at TEXT,
|
||||
disabled INTEGER,
|
||||
container_uuids TEXT,
|
||||
last_used_ip TEXT
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS audit_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -210,6 +237,8 @@ func ensureSchema() error {
|
||||
created_at TEXT,
|
||||
template_id TEXT,
|
||||
user TEXT,
|
||||
ip TEXT,
|
||||
user_agent TEXT,
|
||||
cfg_name TEXT,
|
||||
cfg_virtualization TEXT,
|
||||
cfg_template_id TEXT,
|
||||
@@ -263,9 +292,55 @@ func ensureSchema() error {
|
||||
return fmt.Errorf("failed to create sqlite schema: %v", err)
|
||||
}
|
||||
}
|
||||
return ensureSchemaMigrations()
|
||||
}
|
||||
|
||||
func ensureSchemaMigrations() error {
|
||||
for _, column := range []struct {
|
||||
table string
|
||||
name string
|
||||
def string
|
||||
}{
|
||||
{"api_keys", "scopes", "TEXT"},
|
||||
{"api_keys", "expires_at", "TEXT"},
|
||||
{"api_keys", "disabled", "INTEGER"},
|
||||
{"api_keys", "container_uuids", "TEXT"},
|
||||
{"api_keys", "last_used_ip", "TEXT"},
|
||||
{"tasks", "ip", "TEXT"},
|
||||
{"tasks", "user_agent", "TEXT"},
|
||||
} {
|
||||
if err := ensureColumn(column.table, column.name, column.def); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensureColumn(table, name, def string) error {
|
||||
rows, err := db.Query("PRAGMA table_info(" + table + ")")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var cid int
|
||||
var columnName, columnType string
|
||||
var notNull, pk int
|
||||
var defaultValue interface{}
|
||||
if err := rows.Scan(&cid, &columnName, &columnType, ¬Null, &defaultValue, &pk); err != nil {
|
||||
return err
|
||||
}
|
||||
if columnName == name {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = db.Exec("ALTER TABLE " + table + " ADD COLUMN " + name + " " + def)
|
||||
return err
|
||||
}
|
||||
|
||||
func loadConfigFromDB() (*ClicdConfig, bool, error) {
|
||||
meta := map[string]string{}
|
||||
rows, err := db.Query("SELECT key, value FROM app_meta")
|
||||
@@ -299,6 +374,12 @@ func loadConfigFromDB() (*ClicdConfig, bool, error) {
|
||||
SetupComplete: atob(meta["setup_complete"]),
|
||||
SecurityAutoShutdown: atob(meta["security_auto_shutdown"]),
|
||||
}
|
||||
if raw := strings.TrimSpace(meta["ssl"]); raw != "" {
|
||||
_ = json.Unmarshal([]byte(raw), &cfg.SSL)
|
||||
}
|
||||
if raw := strings.TrimSpace(meta["ssl_certificates"]); raw != "" {
|
||||
_ = json.Unmarshal([]byte(raw), &cfg.SSLCertificates)
|
||||
}
|
||||
|
||||
if cfg.Containers, err = loadContainers(); err != nil {
|
||||
return nil, false, err
|
||||
@@ -391,6 +472,8 @@ func saveConfigToDB() error {
|
||||
}
|
||||
|
||||
func saveMeta(tx *sql.Tx) error {
|
||||
sslJSON, _ := json.Marshal(AppConfig.SSL)
|
||||
sslCertificatesJSON, _ := json.Marshal(AppConfig.SSLCertificates)
|
||||
values := map[string]string{
|
||||
"admin_user": AppConfig.AdminUser,
|
||||
"admin_pass_hash": AppConfig.AdminPassHash,
|
||||
@@ -402,6 +485,8 @@ func saveMeta(tx *sql.Tx) error {
|
||||
"next_ssh_port": strconv.Itoa(AppConfig.NextSSHPort),
|
||||
"setup_complete": btoa(AppConfig.SetupComplete),
|
||||
"security_auto_shutdown": btoa(AppConfig.SecurityAutoShutdown),
|
||||
"ssl": string(sslJSON),
|
||||
"ssl_certificates": string(sslCertificatesJSON),
|
||||
"schema_version": "1",
|
||||
"updated_at": time.Now().Format("2006-01-02 15:04:05"),
|
||||
}
|
||||
@@ -468,8 +553,10 @@ func saveSubUsers(tx *sql.Tx) error {
|
||||
|
||||
func saveAPIKeys(tx *sql.Tx) error {
|
||||
for _, k := range AppConfig.ApiKeys {
|
||||
if _, err := tx.Exec(`INSERT INTO api_keys(id, name, key_hash, prefix, ip_whitelist, created_at, last_used)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`, k.ID, k.Name, k.KeyHash, k.Prefix, k.IPWhitelist, k.CreatedAt, k.LastUsed); err != nil {
|
||||
scopes := encodeStringSlice(k.Scopes)
|
||||
containerUUIDs := encodeStringSlice(k.ContainerUUIDs)
|
||||
if _, err := tx.Exec(`INSERT INTO api_keys(id, name, key_hash, prefix, ip_whitelist, created_at, last_used, scopes, expires_at, disabled, container_uuids, last_used_ip)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, k.ID, k.Name, k.KeyHash, k.Prefix, k.IPWhitelist, k.CreatedAt, k.LastUsed, scopes, k.ExpiresAt, boolInt(k.Disabled), containerUUIDs, k.LastUsedIP); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -498,13 +585,13 @@ func saveTasksDB(tx *sql.Tx) error {
|
||||
for _, task := range AppConfig.Tasks {
|
||||
cfg := parseSavedTaskConfig(task.Config)
|
||||
if _, err := tx.Exec(`INSERT INTO tasks(
|
||||
id, type, container_id, container_name, status, error, created_at, template_id, user,
|
||||
id, type, container_id, container_name, status, error, created_at, template_id, user, ip, user_agent,
|
||||
cfg_name, cfg_virtualization, cfg_template_id, cfg_vcpu, cfg_cpu_percent, cfg_ram_mb, cfg_disk_gb,
|
||||
cfg_network_bw_mbps, cfg_monthly_traffic_gb, cfg_traffic_mode, cfg_traffic_in_gb,
|
||||
cfg_traffic_out_gb, cfg_io_speed_mbps, cfg_port_mapping_count, cfg_snapshot_limit,
|
||||
cfg_assign_ipv6, cfg_expires_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
task.ID, task.Type, task.ContainerID, task.ContainerName, task.Status, task.Error, task.CreatedAt, task.TemplateID, task.User,
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
task.ID, task.Type, task.ContainerID, task.ContainerName, task.Status, task.Error, task.CreatedAt, task.TemplateID, task.User, task.IP, task.UserAgent,
|
||||
cfg.Name, cfg.Virtualization, cfg.TemplateID, cfg.VCPU, cfg.CPUPercent, cfg.RAMMB, cfg.DiskGB,
|
||||
cfg.NetworkBWMbps, cfg.MonthlyTrafficGB, cfg.TrafficMode, cfg.TrafficInGB,
|
||||
cfg.TrafficOutGB, cfg.IOSpeedMBps, cfg.PortMappingCount, cfg.SnapshotLimit,
|
||||
@@ -669,7 +756,7 @@ func loadStringList(table, valueColumn, keyColumn, key string) ([]string, error)
|
||||
}
|
||||
|
||||
func loadAPIKeys() ([]ApiKeyConfig, error) {
|
||||
rows, err := db.Query(`SELECT id, name, key_hash, prefix, ip_whitelist, created_at, last_used FROM api_keys ORDER BY created_at, id`)
|
||||
rows, err := db.Query(`SELECT id, name, key_hash, prefix, ip_whitelist, created_at, last_used, scopes, expires_at, disabled, container_uuids, last_used_ip FROM api_keys ORDER BY created_at, id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -677,9 +764,16 @@ func loadAPIKeys() ([]ApiKeyConfig, error) {
|
||||
result := []ApiKeyConfig{}
|
||||
for rows.Next() {
|
||||
var k ApiKeyConfig
|
||||
if err := rows.Scan(&k.ID, &k.Name, &k.KeyHash, &k.Prefix, &k.IPWhitelist, &k.CreatedAt, &k.LastUsed); err != nil {
|
||||
var scopes, expiresAt, containerUUIDs, lastUsedIP sql.NullString
|
||||
var disabled sql.NullInt64
|
||||
if err := rows.Scan(&k.ID, &k.Name, &k.KeyHash, &k.Prefix, &k.IPWhitelist, &k.CreatedAt, &k.LastUsed, &scopes, &expiresAt, &disabled, &containerUUIDs, &lastUsedIP); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
k.Scopes = decodeStringSlice(scopes.String)
|
||||
k.ExpiresAt = expiresAt.String
|
||||
k.Disabled = disabled.Valid && disabled.Int64 != 0
|
||||
k.ContainerUUIDs = decodeStringSlice(containerUUIDs.String)
|
||||
k.LastUsedIP = lastUsedIP.String
|
||||
result = append(result, k)
|
||||
}
|
||||
return result, rows.Err()
|
||||
@@ -709,7 +803,7 @@ func loadAuditLogs() ([]AuditLog, error) {
|
||||
|
||||
func loadTasks() ([]SavedTask, error) {
|
||||
rows, err := db.Query(`SELECT
|
||||
id, type, container_id, container_name, status, error, created_at, template_id, user,
|
||||
id, type, container_id, container_name, status, error, created_at, template_id, user, ip, user_agent,
|
||||
cfg_name, cfg_virtualization, cfg_template_id, cfg_vcpu, cfg_cpu_percent, cfg_ram_mb, cfg_disk_gb,
|
||||
cfg_network_bw_mbps, cfg_monthly_traffic_gb, cfg_traffic_mode, cfg_traffic_in_gb,
|
||||
cfg_traffic_out_gb, cfg_io_speed_mbps, cfg_port_mapping_count, cfg_snapshot_limit,
|
||||
@@ -725,8 +819,9 @@ func loadTasks() ([]SavedTask, error) {
|
||||
var t SavedTask
|
||||
var cfg savedTaskConfig
|
||||
var assignIPv6 int
|
||||
var ip, userAgent sql.NullString
|
||||
if err := rows.Scan(
|
||||
&t.ID, &t.Type, &t.ContainerID, &t.ContainerName, &t.Status, &t.Error, &t.CreatedAt, &t.TemplateID, &t.User,
|
||||
&t.ID, &t.Type, &t.ContainerID, &t.ContainerName, &t.Status, &t.Error, &t.CreatedAt, &t.TemplateID, &t.User, &ip, &userAgent,
|
||||
&cfg.Name, &cfg.Virtualization, &cfg.TemplateID, &cfg.VCPU, &cfg.CPUPercent, &cfg.RAMMB, &cfg.DiskGB,
|
||||
&cfg.NetworkBWMbps, &cfg.MonthlyTrafficGB, &cfg.TrafficMode, &cfg.TrafficInGB,
|
||||
&cfg.TrafficOutGB, &cfg.IOSpeedMBps, &cfg.PortMappingCount, &cfg.SnapshotLimit,
|
||||
@@ -734,6 +829,8 @@ func loadTasks() ([]SavedTask, error) {
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t.IP = ip.String
|
||||
t.UserAgent = userAgent.String
|
||||
cfg.AssignIPv6 = assignIPv6 != 0
|
||||
result = append(result, t)
|
||||
configs = append(configs, cfg)
|
||||
|
||||
+237
-28
@@ -2,7 +2,9 @@ package kvm
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
@@ -35,6 +37,7 @@ type Manager struct {
|
||||
}
|
||||
|
||||
const ipv6GatewayLinkLocal = "fe80::1"
|
||||
const libvirtDefaultNetworkMarker = "/var/lib/clicd/kvm/default-network.created"
|
||||
|
||||
type usageSample struct {
|
||||
CPUUsec uint64
|
||||
@@ -114,7 +117,22 @@ func ImageDownloadedInfo(id string) (bool, int64) {
|
||||
return true, info.Size()
|
||||
}
|
||||
|
||||
// DownloadProgress reports KVM image download/conversion progress.
|
||||
type DownloadProgress struct {
|
||||
Stage string
|
||||
DownloadedBytes int64
|
||||
TotalBytes int64
|
||||
Percent int
|
||||
}
|
||||
|
||||
// DownloadProgressFunc receives download progress updates.
|
||||
type DownloadProgressFunc func(DownloadProgress)
|
||||
|
||||
func DownloadImage(image Image) error {
|
||||
return DownloadImageWithProgress(context.Background(), image, nil)
|
||||
}
|
||||
|
||||
func DownloadImageWithProgress(ctx context.Context, image Image, progress DownloadProgressFunc) error {
|
||||
if err := os.MkdirAll(CacheDir(), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -134,11 +152,15 @@ func DownloadImage(image Image) error {
|
||||
tmp := target + ".tmp"
|
||||
_ = os.Remove(tmp)
|
||||
if image.Distro == "windows" {
|
||||
if err := downloadFileWithValidator(image.URL, tmp, validateWindowsISOResponse(target)); err != nil {
|
||||
if err := downloadFileWithValidator(ctx, image.URL, tmp, validateWindowsISOResponse(target), progress); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
} else if err := downloadFile(image.URL, tmp); err != nil {
|
||||
} else if err := downloadFile(ctx, image.URL, tmp, progress); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
@@ -153,8 +175,12 @@ func DownloadImage(image Image) error {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err := normalizeQCOW2(tmp, target); err != nil {
|
||||
if progress != nil {
|
||||
progress(DownloadProgress{Stage: "converting", Percent: 100})
|
||||
}
|
||||
if err := normalizeQCOW2(ctx, tmp, target); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
_ = os.Remove(target)
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -168,11 +194,11 @@ func DeleteImage(id string) error {
|
||||
|
||||
type downloadResponseValidator func(*http.Response) error
|
||||
|
||||
func downloadFile(url, target string) error {
|
||||
return downloadFileWithValidator(url, target, nil)
|
||||
func downloadFile(ctx context.Context, url, target string, progress DownloadProgressFunc) error {
|
||||
return downloadFileWithValidator(ctx, url, target, nil, progress)
|
||||
}
|
||||
|
||||
func downloadFileWithValidator(url, target string, validate downloadResponseValidator) error {
|
||||
func downloadFileWithValidator(ctx context.Context, url, target string, validate downloadResponseValidator, progress DownloadProgressFunc) error {
|
||||
client := http.Client{
|
||||
Timeout: 30 * time.Minute,
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
@@ -186,7 +212,7 @@ func downloadFileWithValidator(url, target string, validate downloadResponseVali
|
||||
return nil
|
||||
},
|
||||
}
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -210,7 +236,48 @@ func downloadFileWithValidator(url, target string, validate downloadResponseVali
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
if _, err := io.Copy(out, resp.Body); err != nil {
|
||||
total := resp.ContentLength
|
||||
if total < 0 {
|
||||
total = 0
|
||||
}
|
||||
if progress != nil {
|
||||
progress(DownloadProgress{Stage: "downloading", TotalBytes: total})
|
||||
}
|
||||
buf := make([]byte, 256*1024)
|
||||
var downloaded int64
|
||||
for {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
n, readErr := resp.Body.Read(buf)
|
||||
if n > 0 {
|
||||
written, writeErr := out.Write(buf[:n])
|
||||
downloaded += int64(written)
|
||||
if writeErr != nil {
|
||||
return writeErr
|
||||
}
|
||||
if written != n {
|
||||
return io.ErrShortWrite
|
||||
}
|
||||
if progress != nil {
|
||||
percent := 0
|
||||
if total > 0 {
|
||||
percent = int(downloaded * 100 / total)
|
||||
if percent > 99 {
|
||||
percent = 99
|
||||
}
|
||||
}
|
||||
progress(DownloadProgress{Stage: "downloading", DownloadedBytes: downloaded, TotalBytes: total, Percent: percent})
|
||||
}
|
||||
}
|
||||
if readErr == io.EOF {
|
||||
break
|
||||
}
|
||||
if readErr != nil {
|
||||
return readErr
|
||||
}
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
return out.Sync()
|
||||
@@ -266,11 +333,11 @@ func validateWindowsISO(path, target string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeQCOW2(src, target string) error {
|
||||
func normalizeQCOW2(ctx context.Context, src, target string) error {
|
||||
if err := requireCommand("qemu-img"); err != nil {
|
||||
return err
|
||||
}
|
||||
cmd := exec.Command("qemu-img", "convert", "-O", "qcow2", src, target)
|
||||
cmd := exec.CommandContext(ctx, "qemu-img", "convert", "-O", "qcow2", src, target)
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("qemu-img convert failed: %v, output: %s", err, string(output))
|
||||
}
|
||||
@@ -371,13 +438,21 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig
|
||||
}
|
||||
xml = windowsDomainXML(vmName, int(cfg.VCPU), cfg.RAMMB, diskPath, ImagePath(image.ID), unattendPath, mac, cfg.IOSpeedMBps, cfg.NetworkBWMbps)
|
||||
} else {
|
||||
if image.Desktop != "" {
|
||||
if cfg.RAMMB < 2048 {
|
||||
cfg.RAMMB = 2048
|
||||
}
|
||||
if cfg.DiskGB < 20 {
|
||||
cfg.DiskGB = 20
|
||||
}
|
||||
}
|
||||
if err := createOverlayDisk(ImagePath(image.ID), diskPath, cfg.DiskGB); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := createSeedISO(seedPath, vmName, cfg.Name, sshPassword, mac, ipv6); err != nil {
|
||||
if err := createSeedISO(seedPath, vmName, cfg.Name, sshPassword, mac, ipv6, *image); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
xml = domainXML(vmName, int(cfg.VCPU), cfg.RAMMB, diskPath, seedPath, mac, cfg.IOSpeedMBps, cfg.NetworkBWMbps)
|
||||
xml = domainXML(vmName, int(cfg.VCPU), cfg.RAMMB, diskPath, seedPath, mac, cfg.IOSpeedMBps, cfg.NetworkBWMbps, image.Desktop != "")
|
||||
}
|
||||
xmlPath := filepath.Join(m.instanceDir(vmName), "domain.xml")
|
||||
if err := os.WriteFile(xmlPath, []byte(xml), 0644); err != nil {
|
||||
@@ -639,7 +714,7 @@ func (m *Manager) ReinstallContainer(id int, templateID string) error {
|
||||
return m.StartContainer(id)
|
||||
}
|
||||
|
||||
func (m *Manager) ResetSSHPassword(id int) (string, error) {
|
||||
func (m *Manager) ResetSSHPassword(id int, password string) (string, error) {
|
||||
c := config.FindContainer(id)
|
||||
if c == nil {
|
||||
return "", fmt.Errorf("container not found: %d", id)
|
||||
@@ -650,7 +725,9 @@ func (m *Manager) ResetSSHPassword(id int) (string, error) {
|
||||
if c.Status != "running" {
|
||||
return "", fmt.Errorf("KVM VM must be running before password reset")
|
||||
}
|
||||
password := generateRandomString(16)
|
||||
if strings.TrimSpace(password) == "" {
|
||||
password = generateRandomString(16)
|
||||
}
|
||||
if err := runKVMGuestAgentSSHSetup(c.VirshName(), password); err == nil {
|
||||
c.SSHPassword = password
|
||||
c.SSHHostKey = ""
|
||||
@@ -663,10 +740,14 @@ func (m *Manager) ResetSSHPassword(id int) (string, error) {
|
||||
if err := m.EnsureSSH(id); err != nil {
|
||||
return "", err
|
||||
}
|
||||
chpasswdInput, err := chpasswdStdin("root", password)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
client, err := ssh.Dial("tcp", net.JoinHostPort(c.IP, "22"), &ssh.ClientConfig{
|
||||
User: "root",
|
||||
Auth: []ssh.AuthMethod{ssh.Password(c.SSHPassword)},
|
||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
||||
HostKeyCallback: kvmHostKeyCallback(c),
|
||||
Timeout: 8 * time.Second,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -678,8 +759,8 @@ func (m *Manager) ResetSSHPassword(id int) (string, error) {
|
||||
return "", err
|
||||
}
|
||||
defer session.Close()
|
||||
cmd := fmt.Sprintf("printf 'root:%s\\n' | chpasswd", shellQuote(password))
|
||||
if output, err := session.CombinedOutput(cmd); err != nil {
|
||||
session.Stdin = bytes.NewReader(chpasswdInput)
|
||||
if output, err := session.CombinedOutput("chpasswd"); err != nil {
|
||||
return "", fmt.Errorf("failed to reset password: %v, output: %s", err, string(output))
|
||||
}
|
||||
c.SSHPassword = password
|
||||
@@ -706,7 +787,7 @@ func (m *Manager) ApplyContainerLimits(c *config.Container) error {
|
||||
xml = windowsDomainXML(c.VirshName(), int(c.VCPU), c.RAMMB, c.DiskImage, winISO, unattendISO, c.MACAddress, c.IOSpeedMBps, c.NetworkBWMbps)
|
||||
} else {
|
||||
seedPath := filepath.Join(m.instanceDir(c.VirshName()), "seed.iso")
|
||||
xml = domainXML(c.VirshName(), int(c.VCPU), c.RAMMB, c.DiskImage, seedPath, c.MACAddress, c.IOSpeedMBps, c.NetworkBWMbps)
|
||||
xml = domainXML(c.VirshName(), int(c.VCPU), c.RAMMB, c.DiskImage, seedPath, c.MACAddress, c.IOSpeedMBps, c.NetworkBWMbps, isKVMDesktopTemplate(c.Template))
|
||||
}
|
||||
xmlPath := filepath.Join(m.instanceDir(c.VirshName()), "domain.xml")
|
||||
if err := os.WriteFile(xmlPath, []byte(xml), 0644); err != nil {
|
||||
@@ -731,7 +812,7 @@ func (m *Manager) ensureDomainDefinition(c *config.Container) error {
|
||||
xml = windowsDomainXML(c.VirshName(), int(c.VCPU), c.RAMMB, c.DiskImage, winISO, unattendISO, c.MACAddress, c.IOSpeedMBps, c.NetworkBWMbps)
|
||||
} else {
|
||||
seedPath := filepath.Join(m.instanceDir(c.VirshName()), "seed.iso")
|
||||
xml = domainXML(c.VirshName(), int(c.VCPU), c.RAMMB, c.DiskImage, seedPath, c.MACAddress, c.IOSpeedMBps, c.NetworkBWMbps)
|
||||
xml = domainXML(c.VirshName(), int(c.VCPU), c.RAMMB, c.DiskImage, seedPath, c.MACAddress, c.IOSpeedMBps, c.NetworkBWMbps, isKVMDesktopTemplate(c.Template))
|
||||
}
|
||||
if err := os.WriteFile(xmlPath, []byte(xml), 0644); err != nil {
|
||||
return err
|
||||
@@ -1396,10 +1477,13 @@ func ensureDefaultNetwork() error {
|
||||
if out, err := exec.Command("virsh", "net-define", tmpFile).CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("failed to define libvirt default network: %v, output: %s", err, string(out))
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(libvirtDefaultNetworkMarker), 0755); err == nil {
|
||||
_ = os.WriteFile(libvirtDefaultNetworkMarker, []byte("created-by-clicd\n"), 0644)
|
||||
}
|
||||
}
|
||||
// Start and autostart the default network
|
||||
if out, err := exec.Command("virsh", "net-info", "default").Output(); err == nil {
|
||||
if !strings.Contains(strings.ToLower(string(out)), "active:") || !strings.Contains(strings.ToLower(string(out)), "yes") {
|
||||
if !libvirtNetworkActive(string(out)) {
|
||||
if startOut, startErr := exec.Command("virsh", "net-start", "default").CombinedOutput(); startErr != nil {
|
||||
return fmt.Errorf("failed to start libvirt default network: %v, output: %s", startErr, string(startOut))
|
||||
}
|
||||
@@ -1411,6 +1495,19 @@ func ensureDefaultNetwork() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func libvirtNetworkActive(info string) bool {
|
||||
for _, line := range strings.Split(info, "\n") {
|
||||
key, value, ok := strings.Cut(line, ":")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(key), "Active") {
|
||||
return strings.EqualFold(strings.TrimSpace(value), "yes")
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func createOverlayDisk(base, target string, diskGB int) error {
|
||||
if diskGB < 1 {
|
||||
diskGB = 5
|
||||
@@ -1438,7 +1535,7 @@ func ensureVirtioWinISO() error {
|
||||
virtioURL := "https://fedorapeople.org/groups/virt/virtio-win/direct-downloads/stable-virtio/virtio-win.iso"
|
||||
tmp := virtioPath + ".tmp"
|
||||
_ = os.Remove(tmp)
|
||||
if err := downloadFile(virtioURL, tmp); err != nil {
|
||||
if err := downloadFile(context.Background(), virtioURL, tmp, nil); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return fmt.Errorf("failed to download virtio-win.iso: %v", err)
|
||||
}
|
||||
@@ -1668,8 +1765,11 @@ func shellQuoteWindows(value string) string {
|
||||
return `"` + strings.ReplaceAll(value, `"`, `\"`) + `"`
|
||||
}
|
||||
|
||||
func createSeedISO(seedPath, instanceID, hostname, password, mac, ipv6 string) error {
|
||||
func createSeedISO(seedPath, instanceID, hostname, password, mac, ipv6 string, image Image) error {
|
||||
guestSetup := kvmSSHSetupScript(password)
|
||||
if desktopSetup := kvmDesktopSetupScript(image); desktopSetup != "" {
|
||||
guestSetup += "\n" + desktopSetup
|
||||
}
|
||||
if strings.TrimSpace(ipv6) != "" {
|
||||
guestSetup += "\n" + kvmIPv6SetupScript(ipv6)
|
||||
}
|
||||
@@ -1743,7 +1843,12 @@ func indentScript(script string, spaces int) string {
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func domainXML(name string, vcpu int, ramMB int, diskPath, seedPath, mac string, ioSpeedMBps int, networkBWMbps int) string {
|
||||
func isKVMDesktopTemplate(templateID string) bool {
|
||||
image := FindImage(templateID)
|
||||
return image != nil && image.Desktop != ""
|
||||
}
|
||||
|
||||
func domainXML(name string, vcpu int, ramMB int, diskPath, seedPath, mac string, ioSpeedMBps int, networkBWMbps int, desktop bool) string {
|
||||
if vcpu < 1 {
|
||||
vcpu = 1
|
||||
}
|
||||
@@ -1767,6 +1872,12 @@ func domainXML(name string, vcpu int, ramMB int, diskPath, seedPath, mac string,
|
||||
<outbound average='%d'/>
|
||||
</bandwidth>`, averageKiB, averageKiB)
|
||||
}
|
||||
video := "<video><model type='virtio'/></video>"
|
||||
input := ""
|
||||
if desktop {
|
||||
video = "<video><model type='qxl' ram='65536' vram='65536' heads='1' primary='yes'/></video>"
|
||||
input = "\n\t <input type='tablet' bus='usb'/>"
|
||||
}
|
||||
return fmt.Sprintf(`<domain type='kvm'>
|
||||
<name>%s</name>
|
||||
%s
|
||||
@@ -1810,10 +1921,10 @@ func domainXML(name string, vcpu int, ramMB int, diskPath, seedPath, mac string,
|
||||
<memballoon model='virtio'>
|
||||
<stats period='10'/>
|
||||
</memballoon>
|
||||
<graphics type='vnc' port='-1' autoport='yes' listen='127.0.0.1'/>
|
||||
<video><model type='virtio'/></video>
|
||||
<graphics type='vnc' port='-1' autoport='yes' listen='127.0.0.1'/>%s
|
||||
%s
|
||||
</devices>
|
||||
</domain>`, xmlEscape(name), domainUUIDXML(name), ramMB, ramMB, vcpu, vcpu, xmlEscape(diskPath), iotune, xmlEscape(seedPath), xmlEscape(mac), bandwidth)
|
||||
</domain>`, xmlEscape(name), domainUUIDXML(name), ramMB, ramMB, vcpu, vcpu, xmlEscape(diskPath), iotune, xmlEscape(seedPath), xmlEscape(mac), bandwidth, input, video)
|
||||
}
|
||||
|
||||
func windowsDomainXML(name string, vcpu int, ramMB int, diskPath, winISOPath, unattendISOPath, mac string, ioSpeedMBps int, networkBWMbps int) string {
|
||||
@@ -2122,7 +2233,7 @@ func (m *Manager) EnsureSSH(id int) error {
|
||||
client, err := ssh.Dial("tcp", net.JoinHostPort(c.IP, "22"), &ssh.ClientConfig{
|
||||
User: "root",
|
||||
Auth: []ssh.AuthMethod{ssh.Password(c.SSHPassword)},
|
||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
||||
HostKeyCallback: kvmHostKeyCallback(c),
|
||||
Timeout: 8 * time.Second,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -2263,6 +2374,64 @@ fi
|
||||
`
|
||||
}
|
||||
|
||||
func kvmDesktopSetupScript(image Image) string {
|
||||
if strings.ToLower(strings.TrimSpace(image.Desktop)) != "xfce" {
|
||||
return ""
|
||||
}
|
||||
packages := ""
|
||||
switch image.Distro {
|
||||
case "ubuntu":
|
||||
packages = "xubuntu-desktop"
|
||||
case "debian":
|
||||
packages = "task-xfce-desktop"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
return `if command -v apt-get >/dev/null 2>&1; then
|
||||
{
|
||||
exec >>/var/log/clicd-desktop-setup.log 2>&1
|
||||
echo "CLICD XFCE setup started at $(date -Is)"
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
export APT_LISTCHANGES_FRONTEND=none
|
||||
apt-get update || true
|
||||
apt-get install -y -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold ` + packages + ` || apt-get install -y -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold xfce4 lightdm lightdm-gtk-greeter dbus-x11 xorg || true
|
||||
if command -v useradd >/dev/null 2>&1 && ! id clicd >/dev/null 2>&1; then
|
||||
useradd -m -s /bin/bash clicd || true
|
||||
fi
|
||||
if command -v chpasswd >/dev/null 2>&1 && id clicd >/dev/null 2>&1; then
|
||||
printf 'clicd:%s\n' "$ROOT_PASSWORD" | chpasswd || true
|
||||
fi
|
||||
usermod -aG sudo clicd >/dev/null 2>&1 || true
|
||||
usermod -aG autologin clicd >/dev/null 2>&1 || true
|
||||
if id clicd >/dev/null 2>&1; then
|
||||
printf 'startxfce4\n' >/home/clicd/.xsession || true
|
||||
chown clicd:clicd /home/clicd/.xsession >/dev/null 2>&1 || true
|
||||
fi
|
||||
mkdir -p /etc/lightdm/lightdm.conf.d
|
||||
cat >/etc/lightdm/lightdm.conf.d/50-clicd-autologin.conf <<'EOF'
|
||||
[Seat:*]
|
||||
autologin-user=clicd
|
||||
autologin-user-timeout=0
|
||||
user-session=xfce
|
||||
greeter-session=lightdm-gtk-greeter
|
||||
EOF
|
||||
if [ -x /usr/sbin/lightdm ]; then
|
||||
printf '/usr/sbin/lightdm\n' >/etc/X11/default-display-manager || true
|
||||
fi
|
||||
if command -v systemctl >/dev/null 2>&1; then
|
||||
systemctl daemon-reload >/dev/null 2>&1 || true
|
||||
systemctl set-default graphical.target >/dev/null 2>&1 || true
|
||||
systemctl enable display-manager.service >/dev/null 2>&1 || true
|
||||
systemctl enable lightdm.service >/dev/null 2>&1 || true
|
||||
systemctl restart lightdm.service >/dev/null 2>&1 || systemctl start lightdm.service >/dev/null 2>&1 || true
|
||||
fi
|
||||
apt-get clean || true
|
||||
echo "CLICD XFCE setup finished at $(date -Is)"
|
||||
} || true
|
||||
fi
|
||||
`
|
||||
}
|
||||
|
||||
func qemuGuestPing(name string) error {
|
||||
out, err := exec.Command("virsh", "qemu-agent-command", name, `{"execute":"guest-ping"}`).CombinedOutput()
|
||||
if err != nil {
|
||||
@@ -3030,7 +3199,7 @@ func (m *Manager) applyGuestIPv6OverSSH(c *config.Container) error {
|
||||
client, err := ssh.Dial("tcp", net.JoinHostPort(c.IP, "22"), &ssh.ClientConfig{
|
||||
User: "root",
|
||||
Auth: []ssh.AuthMethod{ssh.Password(c.SSHPassword)},
|
||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
||||
HostKeyCallback: kvmHostKeyCallback(c),
|
||||
Timeout: 8 * time.Second,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -3217,6 +3386,46 @@ func shellQuote(value string) string {
|
||||
return "'" + strings.ReplaceAll(value, "'", "'\"'\"'") + "'"
|
||||
}
|
||||
|
||||
func chpasswdStdin(username, password string) ([]byte, error) {
|
||||
if username == "" || strings.ContainsAny(username, ":\n\r") {
|
||||
return nil, fmt.Errorf("invalid chpasswd username")
|
||||
}
|
||||
if strings.ContainsAny(password, "\n\r") {
|
||||
return nil, fmt.Errorf("password cannot contain newlines")
|
||||
}
|
||||
return []byte(username + ":" + password + "\n"), nil
|
||||
}
|
||||
|
||||
func kvmHostKeyCallback(c *config.Container) ssh.HostKeyCallback {
|
||||
return func(hostname string, remote net.Addr, key ssh.PublicKey) error {
|
||||
return verifyKVMHostKey(c, key, config.SaveConfig)
|
||||
}
|
||||
}
|
||||
|
||||
func verifyKVMHostKey(c *config.Container, key ssh.PublicKey, save func() error) error {
|
||||
if c == nil {
|
||||
return fmt.Errorf("KVM container is nil")
|
||||
}
|
||||
fingerprint := sshHostKeyFingerprint(key)
|
||||
if c.SSHHostKey != "" && c.SSHHostKey != fingerprint {
|
||||
return fmt.Errorf("KVM SSH host key mismatch")
|
||||
}
|
||||
if c.SSHHostKey == "" {
|
||||
c.SSHHostKey = fingerprint
|
||||
if save != nil {
|
||||
if err := save(); err != nil {
|
||||
return fmt.Errorf("failed to save KVM SSH host key: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sshHostKeyFingerprint(key ssh.PublicKey) string {
|
||||
sum := sha256.Sum256(key.Marshal())
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func allocateDefaultEqualPorts(c *config.Container, count int) []int {
|
||||
if count <= 0 {
|
||||
return nil
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
package kvm
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"clicd/internal/config"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
func TestChpasswdStdinPreservesShellMetacharacters(t *testing.T) {
|
||||
password := `pa'";$(touch /tmp/pwned); echo #\\word`
|
||||
got, err := chpasswdStdin("root", password)
|
||||
if err != nil {
|
||||
t.Fatalf("chpasswdStdin returned error: %v", err)
|
||||
}
|
||||
|
||||
want := []byte("root:" + password + "\n")
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("chpasswdStdin = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChpasswdStdinRejectsNewlines(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
username string
|
||||
password string
|
||||
}{
|
||||
{name: "username newline", username: "root\nadmin", password: "safe"},
|
||||
{name: "username colon", username: "root:admin", password: "safe"},
|
||||
{name: "password newline", username: "root", password: "safe\nroot:evil"},
|
||||
{name: "password carriage return", username: "root", password: "safe\rroot:evil"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if _, err := chpasswdStdin(tc.username, tc.password); err == nil {
|
||||
t.Fatal("chpasswdStdin returned nil error")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyKVMHostKeyCapturesAndRejectsMismatch(t *testing.T) {
|
||||
key1 := testSSHPublicKey(t)
|
||||
key2 := testSSHPublicKey(t)
|
||||
|
||||
saves := 0
|
||||
c := &config.Container{}
|
||||
save := func() error {
|
||||
saves++
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := verifyKVMHostKey(c, key1, save); err != nil {
|
||||
t.Fatalf("first host key verification returned error: %v", err)
|
||||
}
|
||||
if c.SSHHostKey == "" {
|
||||
t.Fatal("first host key verification did not capture fingerprint")
|
||||
}
|
||||
if c.SSHHostKey != sshHostKeyFingerprint(key1) {
|
||||
t.Fatalf("captured fingerprint = %q, want %q", c.SSHHostKey, sshHostKeyFingerprint(key1))
|
||||
}
|
||||
if saves != 1 {
|
||||
t.Fatalf("save count = %d, want 1", saves)
|
||||
}
|
||||
|
||||
if err := verifyKVMHostKey(c, key1, save); err != nil {
|
||||
t.Fatalf("same host key verification returned error: %v", err)
|
||||
}
|
||||
if saves != 1 {
|
||||
t.Fatalf("save count after same key = %d, want 1", saves)
|
||||
}
|
||||
|
||||
if err := verifyKVMHostKey(c, key2, save); err == nil {
|
||||
t.Fatal("mismatched host key verification returned nil error")
|
||||
}
|
||||
}
|
||||
|
||||
func testSSHPublicKey(t *testing.T) ssh.PublicKey {
|
||||
t.Helper()
|
||||
_, privateKey, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
signer, err := ssh.NewSignerFromKey(privateKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return signer.PublicKey()
|
||||
}
|
||||
@@ -12,6 +12,7 @@ type Image struct {
|
||||
Arch string `json:"arch"`
|
||||
Description string `json:"description"`
|
||||
URL string `json:"url"`
|
||||
Desktop string `json:"desktop,omitempty"`
|
||||
}
|
||||
|
||||
func GetImages() []Image {
|
||||
@@ -22,6 +23,13 @@ func GetImages() []Image {
|
||||
Description: "Ubuntu 24.04 LTS cloud image for KVM",
|
||||
URL: "https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img",
|
||||
},
|
||||
{
|
||||
ID: "kvm-ubuntu-noble-xfce", Name: "Ubuntu 24.04 XFCE KVM",
|
||||
Distro: "ubuntu", Release: "noble", Arch: "amd64",
|
||||
Description: "Ubuntu 24.04 LTS cloud image with XFCE desktop provisioned via cloud-init",
|
||||
URL: "https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img",
|
||||
Desktop: "xfce",
|
||||
},
|
||||
{
|
||||
ID: "kvm-ubuntu-jammy", Name: "Ubuntu 22.04 KVM",
|
||||
Distro: "ubuntu", Release: "jammy", Arch: "amd64",
|
||||
@@ -34,6 +42,13 @@ func GetImages() []Image {
|
||||
Description: "Debian 12 generic cloud image for KVM",
|
||||
URL: "https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-amd64.qcow2",
|
||||
},
|
||||
{
|
||||
ID: "kvm-debian-bookworm-xfce", Name: "Debian 12 XFCE KVM",
|
||||
Distro: "debian", Release: "bookworm", Arch: "amd64",
|
||||
Description: "Debian 12 generic cloud image with XFCE desktop provisioned via cloud-init",
|
||||
URL: "https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-amd64.qcow2",
|
||||
Desktop: "xfce",
|
||||
},
|
||||
{
|
||||
ID: "kvm-debian-bullseye", Name: "Debian 11 KVM",
|
||||
Distro: "debian", Release: "bullseye", Arch: "amd64",
|
||||
|
||||
+320
-69
@@ -11,14 +11,13 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
|
||||
"clicd/internal/config"
|
||||
)
|
||||
|
||||
@@ -391,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)
|
||||
}
|
||||
|
||||
@@ -403,9 +402,9 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
|
||||
|
||||
// Set root password AFTER shiftRootfsForUnprivileged,
|
||||
// otherwise /etc/shadow ownership breaks and SSHD cannot authenticate.
|
||||
setCmd := m.rootfsCommand(rootfsPath,
|
||||
"sh", "-c", fmt.Sprintf("printf '%%s:%%s\\n' root %s | chpasswd", shellQuote(sshPassword)))
|
||||
setCmd.Run()
|
||||
if err := m.setRootfsPassword(rootfsPath, sshPassword); err != nil {
|
||||
fmt.Printf("Warning: failed to set root password in %s: %v\n", lxcName, err)
|
||||
}
|
||||
|
||||
fmt.Printf("Container %d (%s) created successfully\n", id, cfg.Name)
|
||||
return nil
|
||||
@@ -430,7 +429,7 @@ func (m *Manager) preconfigureNetwork(rootfsPath, templateID string) {
|
||||
content := "auto lo\niface lo inet loopback\n\nauto eth0\niface eth0 inet dhcp\n"
|
||||
_ = os.MkdirAll(filepath.Dir(interfaces), 0755)
|
||||
_ = os.WriteFile(interfaces, []byte(content), 0644)
|
||||
_ = exec.Command("chroot", rootfsPath, "rc-update", "add", "networking", "boot").Run()
|
||||
_ = m.runRootfsCommand(rootfsPath, "rc-update", "add", "networking", "boot")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -452,7 +451,7 @@ method=ignore
|
||||
path := filepath.Join(nmDir, "eth0.nmconnection")
|
||||
_ = os.WriteFile(path, []byte(keyfile), 0600)
|
||||
}
|
||||
_ = exec.Command("chroot", rootfsPath, "systemctl", "enable", "NetworkManager").Run()
|
||||
_ = m.runRootfsCommand(rootfsPath, "systemctl", "enable", "NetworkManager")
|
||||
}
|
||||
|
||||
networkdDir := filepath.Join(rootfsPath, "etc", "systemd", "network")
|
||||
@@ -467,16 +466,19 @@ IPv6AcceptRA=no
|
||||
_ = os.WriteFile(filepath.Join(networkdDir, "10-eth0.network"), []byte(network), 0644)
|
||||
}
|
||||
if !isRHELFamily {
|
||||
_ = exec.Command("chroot", rootfsPath, "systemctl", "enable", "systemd-networkd").Run()
|
||||
_ = m.runRootfsCommand(rootfsPath, "systemctl", "enable", "systemd-networkd")
|
||||
}
|
||||
}
|
||||
|
||||
// preconfigureSSH installs and configures SSH directly in the rootfs before first boot.
|
||||
func (m *Manager) preconfigureSSH(rootfsPath, password, templateID string) error {
|
||||
func (m *Manager) preconfigureSSH(rootfsPath, templateID string) error {
|
||||
_ = templateID
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second)
|
||||
defer cancel()
|
||||
cmd := m.rootfsCommand(rootfsPath, "sh", "-c", sshSetupScript(password, false))
|
||||
cmd, err := m.rootfsCommand(rootfsPath, "sh", "-c", sshSetupScript(false))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cmd = exec.CommandContext(ctx, cmd.Path, cmd.Args[1:]...)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
@@ -990,6 +992,27 @@ func parseSubIDRange(path, user string) (int, error) {
|
||||
return 0, fmt.Errorf("%s must contain a %s subordinate id range with at least 65536 ids", path, user)
|
||||
}
|
||||
|
||||
func (m *Manager) ensureUnprivilegedLXCPathAccess(lxcName string) error {
|
||||
// Unprivileged container root maps to a subordinate host UID, so it needs
|
||||
// execute permission on the LXC parent and container directories to reach
|
||||
// rootfs. Some distributions create /var/lib/lxc as 750/700, which causes
|
||||
// lxc-start to abort with "Could not access /var/lib/lxc".
|
||||
for _, path := range []string{m.LxcPath, filepath.Join(m.LxcPath, lxcName)} {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mode := info.Mode().Perm()
|
||||
if mode&0001 != 0 {
|
||||
continue
|
||||
}
|
||||
if err := os.Chmod(path, mode|0001); err != nil {
|
||||
return fmt.Errorf("failed to fix LXC path permissions for %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) shiftRootfsForUnprivileged(lxcName string) error {
|
||||
uidBase, gidBase, err := unprivilegedIDMap()
|
||||
if err != nil {
|
||||
@@ -997,6 +1020,9 @@ func (m *Manager) shiftRootfsForUnprivileged(lxcName string) error {
|
||||
}
|
||||
rootfsPath := filepath.Join(m.LxcPath, lxcName, "rootfs")
|
||||
marker := filepath.Join(rootfsPath, ".clicd-unprivileged-shifted")
|
||||
if err := m.ensureUnprivilegedLXCPathAccess(lxcName); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := os.Stat(marker); err == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -1005,11 +1031,10 @@ func (m *Manager) shiftRootfsForUnprivileged(lxcName string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rootStat, ok := rootInfo.Sys().(*unix.Stat_t)
|
||||
rootDev, _, _, ok := fileStatFields(rootInfo)
|
||||
if !ok {
|
||||
return fmt.Errorf("failed to read rootfs device for %s", rootfsPath)
|
||||
}
|
||||
rootDev := rootStat.Dev
|
||||
|
||||
if err := filepath.WalkDir(rootfsPath, func(path string, _ os.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
@@ -1019,18 +1044,16 @@ func (m *Manager) shiftRootfsForUnprivileged(lxcName string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stat, ok := info.Sys().(*unix.Stat_t)
|
||||
dev, uid, gid, ok := fileStatFields(info)
|
||||
if !ok {
|
||||
return fmt.Errorf("failed to read uid/gid for %s", path)
|
||||
}
|
||||
if path != rootfsPath && stat.Dev != rootDev {
|
||||
if path != rootfsPath && dev != rootDev {
|
||||
if info.IsDir() {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
uid := int(stat.Uid)
|
||||
gid := int(stat.Gid)
|
||||
if uid >= uidBase && uid < uidBase+65536 && gid >= gidBase && gid < gidBase+65536 {
|
||||
return nil
|
||||
}
|
||||
@@ -1040,7 +1063,7 @@ func (m *Manager) shiftRootfsForUnprivileged(lxcName string) error {
|
||||
if gid >= 0 && gid < 65536 {
|
||||
gid += gidBase
|
||||
}
|
||||
return unix.Lchown(path, uid, gid)
|
||||
return os.Lchown(path, uid, gid)
|
||||
}); err != nil {
|
||||
return fmt.Errorf("failed to shift rootfs ownership for unprivileged LXC: %v", err)
|
||||
}
|
||||
@@ -1048,7 +1071,7 @@ func (m *Manager) shiftRootfsForUnprivileged(lxcName string) error {
|
||||
if err := os.WriteFile(marker, []byte("1\n"), 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := unix.Lchown(marker, uidBase, gidBase); err != nil {
|
||||
if err := os.Lchown(marker, uidBase, gidBase); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1063,6 +1086,48 @@ func (m *Manager) shiftRootfsForUnprivileged(lxcName string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func fileStatFields(info os.FileInfo) (dev uint64, uid int, gid int, ok bool) {
|
||||
if info == nil || info.Sys() == nil {
|
||||
return 0, 0, 0, false
|
||||
}
|
||||
stat := reflect.ValueOf(info.Sys())
|
||||
if stat.Kind() == reflect.Pointer {
|
||||
if stat.IsNil() {
|
||||
return 0, 0, 0, false
|
||||
}
|
||||
stat = stat.Elem()
|
||||
}
|
||||
if stat.Kind() != reflect.Struct {
|
||||
return 0, 0, 0, false
|
||||
}
|
||||
devValue, devOK := numericField(stat, "Dev")
|
||||
uidValue, uidOK := numericField(stat, "Uid")
|
||||
gidValue, gidOK := numericField(stat, "Gid")
|
||||
if !devOK || !uidOK || !gidOK {
|
||||
return 0, 0, 0, false
|
||||
}
|
||||
return devValue, int(uidValue), int(gidValue), true
|
||||
}
|
||||
|
||||
func numericField(v reflect.Value, name string) (uint64, bool) {
|
||||
field := v.FieldByName(name)
|
||||
if !field.IsValid() {
|
||||
return 0, false
|
||||
}
|
||||
switch field.Kind() {
|
||||
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr:
|
||||
return field.Uint(), true
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||
value := field.Int()
|
||||
if value < 0 {
|
||||
return 0, false
|
||||
}
|
||||
return uint64(value), true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) unmountRootfsChildMounts(rootfsPath string) {
|
||||
rootAbs, err := filepath.Abs(rootfsPath)
|
||||
if err != nil {
|
||||
@@ -1584,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()
|
||||
@@ -1596,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 != "" {
|
||||
@@ -1614,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))
|
||||
@@ -1628,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()
|
||||
@@ -1635,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.
|
||||
@@ -1761,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
|
||||
@@ -1823,14 +1899,17 @@ pgrep -x sshd >/dev/null 2>&1 || exit 33
|
||||
}
|
||||
|
||||
// ResetSSHPassword resets the root password of a container
|
||||
func (m *Manager) ResetSSHPassword(id int) (string, error) {
|
||||
func (m *Manager) ResetSSHPassword(id int, password string) (string, error) {
|
||||
c := config.FindContainer(id)
|
||||
if c == nil {
|
||||
return "", fmt.Errorf("container not found: %d", id)
|
||||
}
|
||||
lxcName := c.LxcName()
|
||||
|
||||
newPassword := generateRandomString(16)
|
||||
newPassword := strings.TrimSpace(password)
|
||||
if newPassword == "" {
|
||||
newPassword = generateRandomString(16)
|
||||
}
|
||||
|
||||
if c.Status == "running" {
|
||||
c.SSHPassword = newPassword
|
||||
@@ -1843,13 +1922,11 @@ func (m *Manager) ResetSSHPassword(id int) (string, error) {
|
||||
return "", err
|
||||
}
|
||||
rootfsPath := filepath.Join(m.LxcPath, lxcName, "rootfs")
|
||||
if err := m.preconfigureSSH(rootfsPath, newPassword, c.Template); err != nil {
|
||||
if err := m.preconfigureSSH(rootfsPath, c.Template); err != nil {
|
||||
return "", fmt.Errorf("failed to configure SSH: %v", err)
|
||||
}
|
||||
cmd := m.rootfsCommand(rootfsPath, "sh", "-c", fmt.Sprintf("printf '%%s:%%s\\n' root %s | chpasswd", shellQuote(newPassword)))
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to set password: %v, output: %s", err, string(output))
|
||||
if err := m.setRootfsPassword(rootfsPath, newPassword); err != nil {
|
||||
return "", fmt.Errorf("failed to set password: %v", err)
|
||||
}
|
||||
c.SSHPassword = newPassword
|
||||
config.SaveConfig()
|
||||
@@ -1858,22 +1935,133 @@ func (m *Manager) ResetSSHPassword(id int) (string, error) {
|
||||
return newPassword, nil
|
||||
}
|
||||
|
||||
func (m *Manager) rootfsCommand(rootfsPath string, args ...string) *exec.Cmd {
|
||||
marker := filepath.Join(rootfsPath, ".clicd-unprivileged-shifted")
|
||||
func (m *Manager) rootfsCommand(rootfsPath string, args ...string) (*exec.Cmd, error) {
|
||||
cleanRootfsPath, err := m.safeRootfsPath(rootfsPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
safeArgs, err := safeRootfsCommandArgs(args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
marker := filepath.Join(cleanRootfsPath, ".clicd-unprivileged-shifted")
|
||||
if _, err := os.Stat(marker); err == nil {
|
||||
uidBase, gidBase, mapErr := unprivilegedIDMap()
|
||||
if mapErr == nil {
|
||||
cmdArgs := []string{
|
||||
"-m", fmt.Sprintf("u:0:%d:65536", uidBase),
|
||||
"-m", fmt.Sprintf("g:0:%d:65536", gidBase),
|
||||
"--", "chroot", rootfsPath,
|
||||
"--", "chroot", "--", cleanRootfsPath,
|
||||
}
|
||||
cmdArgs = append(cmdArgs, args...)
|
||||
return exec.Command("lxc-usernsexec", cmdArgs...)
|
||||
cmdArgs = append(cmdArgs, safeArgs...)
|
||||
return exec.Command("lxc-usernsexec", cmdArgs...), nil
|
||||
}
|
||||
}
|
||||
cmdArgs := append([]string{rootfsPath}, args...)
|
||||
return exec.Command("chroot", cmdArgs...)
|
||||
cmdArgs := append([]string{"--", cleanRootfsPath}, safeArgs...)
|
||||
return exec.Command("chroot", cmdArgs...), nil
|
||||
}
|
||||
|
||||
func (m *Manager) runRootfsCommand(rootfsPath string, args ...string) error {
|
||||
cmd, err := m.rootfsCommand(rootfsPath, args...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func (m *Manager) setRootfsPassword(rootfsPath, password string) error {
|
||||
if err := validateRootPassword(password); err != nil {
|
||||
return err
|
||||
}
|
||||
cmd, err := m.rootfsCommand(rootfsPath, "chpasswd")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cmd.Stdin = strings.NewReader(rootPasswordInput(password))
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("%v, output: %s", err, string(output))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func safeRootfsCommandArgs(args []string) ([]string, error) {
|
||||
if len(args) == 0 {
|
||||
return nil, fmt.Errorf("empty rootfs command")
|
||||
}
|
||||
allowed := map[string]bool{
|
||||
"chpasswd": true,
|
||||
"rc-update": true,
|
||||
"sh": true,
|
||||
"systemctl": true,
|
||||
}
|
||||
if !allowed[args[0]] || strings.HasPrefix(args[0], "-") || strings.Contains(args[0], "/") {
|
||||
return nil, fmt.Errorf("rootfs command is not allowed: %s", args[0])
|
||||
}
|
||||
for _, arg := range args {
|
||||
if strings.ContainsRune(arg, '\x00') {
|
||||
return nil, fmt.Errorf("rootfs command argument contains NUL byte")
|
||||
}
|
||||
}
|
||||
if args[0] == "sh" {
|
||||
if len(args) != 3 || args[1] != "-c" {
|
||||
return nil, fmt.Errorf("unsupported rootfs shell invocation")
|
||||
}
|
||||
if !isCLICDManagedRootfsScript(args[2]) {
|
||||
return nil, fmt.Errorf("refusing unmanaged rootfs shell script")
|
||||
}
|
||||
}
|
||||
return append([]string(nil), args...), nil
|
||||
}
|
||||
|
||||
func isCLICDManagedRootfsScript(script string) bool {
|
||||
return strings.Contains(script, "99-clicd.conf") &&
|
||||
strings.Contains(script, "install_sshd") &&
|
||||
!strings.Contains(script, "ROOT_PASSWORD") &&
|
||||
!strings.Contains(script, "chpasswd")
|
||||
}
|
||||
|
||||
func (m *Manager) safeRootfsPath(rootfsPath string) (string, error) {
|
||||
if rootfsPath == "" {
|
||||
return "", fmt.Errorf("empty rootfs path")
|
||||
}
|
||||
if !filepath.IsAbs(rootfsPath) {
|
||||
return "", fmt.Errorf("rootfs path must be absolute: %s", rootfsPath)
|
||||
}
|
||||
|
||||
cleanRootfsPath := filepath.Clean(rootfsPath)
|
||||
cleanLxcPath, err := filepath.Abs(m.LxcPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to resolve LXC path: %v", err)
|
||||
}
|
||||
cleanLxcPath = filepath.Clean(cleanLxcPath)
|
||||
|
||||
if cleanRootfsPath == cleanLxcPath {
|
||||
return "", fmt.Errorf("refusing LXC base path as rootfs: %s", cleanRootfsPath)
|
||||
}
|
||||
if filepath.Base(cleanRootfsPath) != "rootfs" {
|
||||
return "", fmt.Errorf("refusing non-rootfs path: %s", cleanRootfsPath)
|
||||
}
|
||||
if filepath.Dir(cleanRootfsPath) == cleanLxcPath {
|
||||
return "", fmt.Errorf("refusing rootfs directly under LXC path: %s", cleanRootfsPath)
|
||||
}
|
||||
|
||||
rel, err := filepath.Rel(cleanLxcPath, cleanRootfsPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to validate rootfs path: %v", err)
|
||||
}
|
||||
if rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) || filepath.IsAbs(rel) {
|
||||
return "", fmt.Errorf("refusing unsafe rootfs path: %s", cleanRootfsPath)
|
||||
}
|
||||
parts := strings.Split(rel, string(os.PathSeparator))
|
||||
if len(parts) != 2 || parts[1] != "rootfs" {
|
||||
return "", fmt.Errorf("refusing nested or malformed rootfs path: %s", cleanRootfsPath)
|
||||
}
|
||||
if strings.HasPrefix(parts[0], "-") || !regexp.MustCompile(`^[A-Za-z0-9_.-]+$`).MatchString(parts[0]) {
|
||||
return "", fmt.Errorf("refusing unsafe container directory name: %s", parts[0])
|
||||
}
|
||||
return cleanRootfsPath, nil
|
||||
}
|
||||
|
||||
func (m *Manager) cleanupContainerStorage(lxcName string) error {
|
||||
@@ -2115,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)
|
||||
@@ -2138,26 +2405,10 @@ func (m *Manager) ReinstallContainer(id int, templateID string) error {
|
||||
// Clean port mappings temporarily
|
||||
m.CleanPortMappings(id)
|
||||
|
||||
// Destroy old LXC but keep config
|
||||
exec.Command("lxc-stop", "-n", lxcName, "-k").Run()
|
||||
exec.Command("lxc-destroy", "-n", lxcName, "-f").Run()
|
||||
rootfs := filepath.Join(m.LxcPath, lxcName, "rootfs")
|
||||
exec.Command("umount", "-R", "-l", rootfs).Run()
|
||||
os.RemoveAll(rootfs)
|
||||
os.Remove(filepath.Join(m.LxcPath, lxcName, "rootfs.img"))
|
||||
|
||||
// Create new container with same LXC name (preserves ID)
|
||||
cmd := exec.Command("lxc-create",
|
||||
"-n", lxcName,
|
||||
"-t", "download",
|
||||
"--",
|
||||
"-d", tmpl.Distro,
|
||||
"-r", tmpl.Release,
|
||||
"-a", tmpl.Arch,
|
||||
)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("lxc-create failed: %v, output: %s", err, string(output))
|
||||
// Download the new OS into a temporary container, then replace only the
|
||||
// existing rootfs. The target container directory and config are preserved.
|
||||
if err := m.replaceRootfsFromTemplate(lxcName, tmpl); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := m.applyDiskLimit(lxcName, c.DiskGB); err != nil {
|
||||
@@ -2197,15 +2448,15 @@ func (m *Manager) ReinstallContainer(id int, templateID string) error {
|
||||
if c.SSHPassword == "" {
|
||||
c.SSHPassword = generateRandomString(16)
|
||||
}
|
||||
if err := m.preconfigureSSH(rootfsPath, c.SSHPassword, templateID); err != nil {
|
||||
if err := m.preconfigureSSH(rootfsPath, templateID); err != nil {
|
||||
fmt.Printf("Warning: failed to pre-configure SSH in %s after reinstall: %v\n", lxcName, err)
|
||||
}
|
||||
if err := m.shiftRootfsForUnprivileged(lxcName); err != nil {
|
||||
return err
|
||||
}
|
||||
setCmd := m.rootfsCommand(rootfsPath,
|
||||
"sh", "-c", fmt.Sprintf("printf '%%s:%%s\\n' root %s | chpasswd", shellQuote(c.SSHPassword)))
|
||||
setCmd.Run()
|
||||
if err := m.setRootfsPassword(rootfsPath, c.SSHPassword); err != nil {
|
||||
fmt.Printf("Warning: failed to set root password in %s after reinstall: %v\n", lxcName, err)
|
||||
}
|
||||
|
||||
// Update template and keep everything else the same
|
||||
c.Template = templateID
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
package lxc
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRootfsCommandAddsSeparatorForAllowedCommand(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
rootfs := filepath.Join(base, "ct-1", "rootfs")
|
||||
if err := os.MkdirAll(rootfs, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
m := &Manager{LxcPath: base}
|
||||
cmd, err := m.rootfsCommand(rootfs, "chpasswd")
|
||||
if err != nil {
|
||||
t.Fatalf("rootfsCommand returned error: %v", err)
|
||||
}
|
||||
|
||||
want := []string{"chroot", "--", rootfs, "chpasswd"}
|
||||
if !reflect.DeepEqual(cmd.Args, want) {
|
||||
t.Fatalf("cmd.Args = %#v, want %#v", cmd.Args, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootfsCommandRejectsUnmanagedCommand(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
rootfs := filepath.Join(base, "ct-1", "rootfs")
|
||||
if err := os.MkdirAll(rootfs, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
m := &Manager{LxcPath: base}
|
||||
if _, err := m.rootfsCommand(rootfs, "true"); err == nil {
|
||||
t.Fatal("rootfsCommand allowed unmanaged command")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootfsCommandRejectsLeadingDashContainerName(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
rootfs := filepath.Join(base, "-ct", "rootfs")
|
||||
if err := os.MkdirAll(rootfs, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
m := &Manager{LxcPath: base}
|
||||
if _, err := m.rootfsCommand(rootfs, "chpasswd"); err == nil {
|
||||
t.Fatal("rootfsCommand allowed leading-dash container name")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootfsCommandRejectsUnsafeRootfsPaths(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
outside := t.TempDir()
|
||||
m := &Manager{LxcPath: base}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
}{
|
||||
{name: "outside base", path: filepath.Join(outside, "ct-1", "rootfs")},
|
||||
{name: "base path", path: base},
|
||||
{name: "not rootfs", path: filepath.Join(base, "ct-1", "not-rootfs")},
|
||||
{name: "rootfs directly under base", path: filepath.Join(base, "rootfs")},
|
||||
{name: "relative rootfs", path: filepath.Join("ct-1", "rootfs")},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if _, err := m.rootfsCommand(tc.path, "chpasswd"); err == nil {
|
||||
t.Fatalf("rootfsCommand(%q) returned nil error", tc.path)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeRootfsPathRejectsSiblingPrefix(t *testing.T) {
|
||||
parent := t.TempDir()
|
||||
base := filepath.Join(parent, "lxc")
|
||||
siblingRootfs := filepath.Join(parent, "lxc-evil", "ct-1", "rootfs")
|
||||
m := &Manager{LxcPath: base}
|
||||
|
||||
if _, err := m.safeRootfsPath(siblingRootfs); err == nil || !strings.Contains(err.Error(), "unsafe rootfs path") {
|
||||
t.Fatalf("safeRootfsPath returned %v, want unsafe rootfs path error", err)
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"clicd/internal/api"
|
||||
@@ -23,7 +25,7 @@ func corsMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
||||
w.Header().Set("Vary", "Origin")
|
||||
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||
}
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-API-Key")
|
||||
|
||||
if r.Method == http.MethodOptions {
|
||||
@@ -75,16 +77,20 @@ func setupRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/change-password", corsMiddleware(api.AdminMiddleware(api.HandleAdminPasswordChange)))
|
||||
mux.HandleFunc("/api/change-username", corsMiddleware(api.AdminMiddleware(api.HandleAdminUsernameChange)))
|
||||
mux.HandleFunc("/api/login-logs", corsMiddleware(api.AdminMiddleware(api.HandleLoginLogs)))
|
||||
mux.HandleFunc("/api/ssl", corsMiddleware(api.AdminMiddleware(api.HandleSSLSettings)))
|
||||
mux.HandleFunc("/api/containers", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleContainers))))
|
||||
mux.HandleFunc("/api/containers/list", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleContainerListAlias))))
|
||||
mux.HandleFunc("/api/containers/", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleSingleContainer))))
|
||||
mux.HandleFunc("/api/templates", corsMiddleware(api.AuthMiddleware(api.HandleTemplates)))
|
||||
mux.HandleFunc("/api/images", corsMiddleware(api.AdminMiddleware(api.HandleImages)))
|
||||
mux.HandleFunc("/api/images/download", corsMiddleware(api.AdminMiddleware(api.HandleImageDownload)))
|
||||
mux.HandleFunc("/api/images/cancel", corsMiddleware(api.AdminMiddleware(api.HandleImageCancel)))
|
||||
mux.HandleFunc("/api/images/delete", corsMiddleware(api.AdminMiddleware(api.HandleImageDelete)))
|
||||
mux.HandleFunc("/api/images/toggle", corsMiddleware(api.AdminMiddleware(api.HandleImageToggle)))
|
||||
mux.HandleFunc("/api/images/enabled", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleEnabledImages))))
|
||||
mux.HandleFunc("/api/dashboard", corsMiddleware(api.AdminMiddleware(api.HandleDashboard)))
|
||||
mux.HandleFunc("/api/host-info", corsMiddleware(api.AdminMiddleware(api.HandleHostInfo)))
|
||||
mux.HandleFunc("/api/host-report", corsMiddleware(api.AdminMiddleware(api.HandleHostReport)))
|
||||
mux.HandleFunc("/api/snapshots", corsMiddleware(api.AdminMiddleware(api.HandleSnapshots)))
|
||||
mux.HandleFunc("/api/routing", corsMiddleware(api.AdminMiddleware(api.HandleRouting)))
|
||||
mux.HandleFunc("/api/ipv6/status", corsMiddleware(api.AdminMiddleware(api.HandleIPv6Status)))
|
||||
@@ -112,6 +118,50 @@ func setupRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/api-keys", corsMiddleware(api.AdminMiddleware(api.HandleApiKeys)))
|
||||
mux.HandleFunc("/api/api-keys/", corsMiddleware(api.AdminMiddleware(api.HandleApiKeyDelete)))
|
||||
|
||||
// Versioned external API routes
|
||||
mux.HandleFunc("/api/v1/dashboard", corsMiddleware(api.AuthMiddleware(api.HandleDashboard)))
|
||||
mux.HandleFunc("/api/v1/containers", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleContainers))))
|
||||
mux.HandleFunc("/api/v1/containers/list", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleContainerListAlias))))
|
||||
mux.HandleFunc("/api/v1/containers/", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleSingleContainer))))
|
||||
mux.HandleFunc("/api/v1/templates", corsMiddleware(api.AuthMiddleware(api.HandleTemplates)))
|
||||
mux.HandleFunc("/api/v1/images", corsMiddleware(api.AuthMiddleware(api.HandleImages)))
|
||||
mux.HandleFunc("/api/v1/images/download", corsMiddleware(api.AuthMiddleware(api.HandleImageDownload)))
|
||||
mux.HandleFunc("/api/v1/images/cancel", corsMiddleware(api.AuthMiddleware(api.HandleImageCancel)))
|
||||
mux.HandleFunc("/api/v1/images/delete", corsMiddleware(api.AuthMiddleware(api.HandleImageDelete)))
|
||||
mux.HandleFunc("/api/v1/images/toggle", corsMiddleware(api.AuthMiddleware(api.HandleImageToggle)))
|
||||
mux.HandleFunc("/api/v1/images/enabled", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleEnabledImages))))
|
||||
mux.HandleFunc("/api/v1/host-info", corsMiddleware(api.AuthMiddleware(api.HandleHostInfo)))
|
||||
mux.HandleFunc("/api/v1/host-report", corsMiddleware(api.AuthMiddleware(api.HandleHostReport)))
|
||||
mux.HandleFunc("/api/v1/snapshots", corsMiddleware(api.AuthMiddleware(api.ScopeMiddleware("snapshot:read", api.HandleSnapshots))))
|
||||
mux.HandleFunc("/api/v1/routing", corsMiddleware(api.AuthMiddleware(api.HandleRouting)))
|
||||
mux.HandleFunc("/api/v1/ipv6/status", corsMiddleware(api.AuthMiddleware(api.HandleIPv6Status)))
|
||||
mux.HandleFunc("/api/v1/tasks", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleTasks))))
|
||||
mux.HandleFunc("/api/v1/tasks/", corsMiddleware(api.AuthMiddleware(api.HandleTaskDelete)))
|
||||
mux.HandleFunc("/api/v1/batch-create", corsMiddleware(api.AuthMiddleware(api.HandleBatchCreate)))
|
||||
mux.HandleFunc("/api/v1/batch-action", corsMiddleware(api.AuthMiddleware(api.HandleBatchAction)))
|
||||
mux.HandleFunc("/api/v1/sub-user/create", corsMiddleware(api.AuthMiddleware(api.HandleSubUserCreate)))
|
||||
mux.HandleFunc("/api/v1/sub-users", corsMiddleware(api.AuthMiddleware(api.HandleSubUserList)))
|
||||
mux.HandleFunc("/api/v1/sub-users/", corsMiddleware(api.AuthMiddleware(api.HandleSubUserAction)))
|
||||
mux.HandleFunc("/api/v1/audit-logs", corsMiddleware(api.AuthMiddleware(api.HandleAuditLogs)))
|
||||
mux.HandleFunc("/api/v1/login-logs", corsMiddleware(api.AuthMiddleware(api.HandleLoginLogs)))
|
||||
mux.HandleFunc("/api/v1/ssl", corsMiddleware(api.AdminMiddleware(api.HandleSSLSettings)))
|
||||
mux.HandleFunc("/api/v1/security/alerts", corsMiddleware(api.AuthMiddleware(api.ScopeMiddleware("security:read", api.HandleSecurityAlerts))))
|
||||
mux.HandleFunc("/api/v1/security/check", corsMiddleware(api.AuthMiddleware(api.ScopeMiddleware("security:check", api.HandleSecurityCheck))))
|
||||
mux.HandleFunc("/api/v1/security/logs", corsMiddleware(api.AuthMiddleware(api.ScopeMiddleware("security:read", api.HandleSecurityLogs))))
|
||||
mux.HandleFunc("/api/v1/security/summary", corsMiddleware(api.AuthMiddleware(api.ScopeMiddleware("security:read", api.HandleContainerSecuritySummary))))
|
||||
mux.HandleFunc("/api/v1/security/settings", corsMiddleware(api.AuthMiddleware(api.HandleSecuritySettings)))
|
||||
mux.HandleFunc("/api/v1/ssh-ticket", corsMiddleware(api.AuthMiddleware(api.HandleWebSSHTicket)))
|
||||
mux.HandleFunc("/api/v1/vnc-ticket", corsMiddleware(api.AuthMiddleware(api.HandleVNCTicket)))
|
||||
mux.HandleFunc("/api/v1/api-keys", corsMiddleware(api.AuthMiddleware(api.HandleApiKeys)))
|
||||
mux.HandleFunc("/api/v1/api-keys/", corsMiddleware(api.AuthMiddleware(api.HandleApiKeyDelete)))
|
||||
mux.HandleFunc("/api/v1/swap", corsMiddleware(api.AuthMiddleware(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet {
|
||||
api.HandleSwapInfo(w, r)
|
||||
return
|
||||
}
|
||||
api.HandleSwapManage(w, r)
|
||||
})))
|
||||
|
||||
// Version (public)
|
||||
mux.HandleFunc("/api/version", corsMiddleware(api.HandleVersion))
|
||||
|
||||
@@ -162,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,7 +1,7 @@
|
||||
package version
|
||||
|
||||
var (
|
||||
Version = "1.1.0"
|
||||
Version = "1.1.8"
|
||||
Repo = "MengMengCode/CLICD"
|
||||
)
|
||||
|
||||
@@ -11,12 +11,3 @@ func Current() string {
|
||||
}
|
||||
return Version
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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 |
Generated
+563
-713
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "clicd-frontend",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.8",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -9,7 +9,7 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@novnc/novnc": "1.6.0",
|
||||
"@novnc/novnc": "1.5.0",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"axios": "^1.7.7",
|
||||
@@ -21,11 +21,11 @@
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.15",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.4.11"
|
||||
"vite": "^8.0.16"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import ContainerDetail from './pages/ContainerDetail'
|
||||
import Security from './pages/Security'
|
||||
import AuditLogs from './pages/AuditLogs'
|
||||
import ApiIntegration from './pages/ApiIntegration'
|
||||
import HostReport from './pages/HostReport'
|
||||
import Settings from './pages/Settings'
|
||||
import ImageManagement from './pages/ImageManagement'
|
||||
import Snapshots from './pages/Snapshots'
|
||||
@@ -64,6 +65,7 @@ function App() {
|
||||
<Route path="routing" element={<Routing />} />
|
||||
<Route path="audit-logs" element={<AuditLogs />} />
|
||||
<Route path="api-integration" element={<ApiIntegration />} />
|
||||
<Route path="host-report" element={<HostReport />} />
|
||||
<Route path="sub-users" element={<SubUserManagement />} />
|
||||
<Route path="settings" element={<Settings />} />
|
||||
</Route>
|
||||
|
||||
@@ -56,7 +56,7 @@ export default function ContainerCard({ container, onRefresh }: ContainerCardPro
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-gray-100 rounded-lg flex items-center justify-center">
|
||||
<div className="w-10 h-10 flex items-center justify-center">
|
||||
<Server className="w-5 h-5 text-gray-700" />
|
||||
</div>
|
||||
<div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Code2,
|
||||
Cpu,
|
||||
Camera,
|
||||
LayoutDashboard,
|
||||
LogOut,
|
||||
@@ -71,6 +72,7 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
|
||||
const isRoutingPage = location.pathname.startsWith('/routing')
|
||||
const isAuditLogsPage = location.pathname.startsWith('/audit-logs')
|
||||
const isApiIntegrationPage = location.pathname.startsWith('/api-integration')
|
||||
const isHostReportPage = location.pathname.startsWith('/host-report')
|
||||
const isSecurityPage = location.pathname.startsWith('/security')
|
||||
const isSettingsPage = location.pathname.startsWith('/settings')
|
||||
|
||||
@@ -83,14 +85,14 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
|
||||
<div className="flex items-center justify-between h-14 px-4 border-b border-gray-200 dark:border-gray-700">
|
||||
{!collapsed && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-7 h-7 bg-gray-100 rounded flex items-center justify-center dark:bg-gray-800">
|
||||
<div className="w-7 h-7 flex items-center justify-center">
|
||||
<AppIcon className="w-5 h-5" />
|
||||
</div>
|
||||
<span className="font-bold text-black text-sm dark:text-white">CLICD</span>
|
||||
</div>
|
||||
)}
|
||||
{collapsed && (
|
||||
<div className="w-7 h-7 bg-gray-100 rounded flex items-center justify-center mx-auto dark:bg-gray-800">
|
||||
<div className="w-7 h-7 flex items-center justify-center mx-auto">
|
||||
<AppIcon className="w-5 h-5" />
|
||||
</div>
|
||||
)}
|
||||
@@ -222,6 +224,18 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
|
||||
{!collapsed && <span>API 集成</span>}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => navigate('/host-report')}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
|
||||
isHostReportPage
|
||||
? 'bg-black text-white dark:bg-white dark:text-black'
|
||||
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'
|
||||
}`}
|
||||
>
|
||||
<Cpu className="w-4 h-4" />
|
||||
{!collapsed && <span>宿主机信息</span>}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => navigate('/settings')}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
|
||||
|
||||
@@ -1,8 +1,39 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Monitor, RefreshCw, Send, X } from 'lucide-react'
|
||||
import RFB from '@novnc/novnc'
|
||||
import RFBModule from '@novnc/novnc/lib/rfb'
|
||||
import { createVNCTicket, getWebVNCUrl } from '../services/api'
|
||||
|
||||
type RFBConstructor = new (
|
||||
target: HTMLElement,
|
||||
url: string,
|
||||
options?: { credentials?: Record<string, string>; shared?: boolean; repeaterID?: string; wsProtocols?: string[] }
|
||||
) => RFBInstance
|
||||
|
||||
interface RFBInstance extends EventTarget {
|
||||
scaleViewport: boolean
|
||||
resizeSession: boolean
|
||||
focusOnClick: boolean
|
||||
viewOnly: boolean
|
||||
qualityLevel: number
|
||||
compressionLevel: number
|
||||
background: string
|
||||
disconnect(): void
|
||||
sendCtrlAltDel(): void
|
||||
}
|
||||
|
||||
const RFB = resolveRFBConstructor(RFBModule)
|
||||
|
||||
function resolveRFBConstructor(moduleValue: unknown): RFBConstructor {
|
||||
if (typeof moduleValue === 'function') {
|
||||
return moduleValue as RFBConstructor
|
||||
}
|
||||
const maybeDefault = (moduleValue as { default?: unknown })?.default
|
||||
if (typeof maybeDefault === 'function') {
|
||||
return maybeDefault as RFBConstructor
|
||||
}
|
||||
throw new Error('noVNC RFB constructor is unavailable')
|
||||
}
|
||||
|
||||
interface WebVNCViewerProps {
|
||||
containerName: string
|
||||
onClose: () => void
|
||||
@@ -10,7 +41,7 @@ interface WebVNCViewerProps {
|
||||
|
||||
export default function WebVNCViewer({ containerName, onClose }: WebVNCViewerProps) {
|
||||
const screenRef = useRef<HTMLDivElement>(null)
|
||||
const rfbRef = useRef<RFB | null>(null)
|
||||
const rfbRef = useRef<RFBInstance | null>(null)
|
||||
const [status, setStatus] = useState<'connecting' | 'connected' | 'disconnected' | 'error'>('connecting')
|
||||
const [errorMsg, setErrorMsg] = useState('')
|
||||
|
||||
@@ -21,6 +52,46 @@ export default function WebVNCViewer({ containerName, onClose }: WebVNCViewerPro
|
||||
}
|
||||
}
|
||||
|
||||
const ensureResizeObserver = () => {
|
||||
if ('ResizeObserver' in window) return
|
||||
|
||||
class FallbackResizeObserver {
|
||||
private target: Element | null = null
|
||||
private timer = 0
|
||||
private lastWidth = -1
|
||||
private lastHeight = -1
|
||||
|
||||
constructor(private callback: ResizeObserverCallback) {}
|
||||
|
||||
observe = (target: Element) => {
|
||||
this.target = target
|
||||
this.check()
|
||||
this.timer = window.setInterval(this.check, 250)
|
||||
window.addEventListener('resize', this.check)
|
||||
}
|
||||
|
||||
unobserve = () => this.disconnect()
|
||||
|
||||
disconnect = () => {
|
||||
if (this.timer) window.clearInterval(this.timer)
|
||||
this.timer = 0
|
||||
window.removeEventListener('resize', this.check)
|
||||
this.target = null
|
||||
}
|
||||
|
||||
private check = () => {
|
||||
if (!this.target) return
|
||||
const contentRect = this.target.getBoundingClientRect()
|
||||
if (contentRect.width === this.lastWidth && contentRect.height === this.lastHeight) return
|
||||
this.lastWidth = contentRect.width
|
||||
this.lastHeight = contentRect.height
|
||||
this.callback([{ target: this.target, contentRect } as ResizeObserverEntry], this as unknown as ResizeObserver)
|
||||
}
|
||||
}
|
||||
|
||||
;(window as unknown as { ResizeObserver: typeof ResizeObserver }).ResizeObserver = FallbackResizeObserver as unknown as typeof ResizeObserver
|
||||
}
|
||||
|
||||
const connect = async () => {
|
||||
const target = screenRef.current
|
||||
if (!target) return
|
||||
@@ -40,6 +111,7 @@ export default function WebVNCViewer({ containerName, onClose }: WebVNCViewerPro
|
||||
setErrorMsg(error.response?.data?.message || 'WebVNC ticket 创建失败,请重新登录后再试')
|
||||
return
|
||||
}
|
||||
|
||||
if (!ticket) {
|
||||
setStatus('error')
|
||||
setErrorMsg('WebVNC ticket 为空,请重新登录后再试')
|
||||
@@ -47,16 +119,17 @@ export default function WebVNCViewer({ containerName, onClose }: WebVNCViewerPro
|
||||
}
|
||||
|
||||
try {
|
||||
const rfb = new RFB(target, getWebVNCUrl(containerName, ticket))
|
||||
ensureResizeObserver()
|
||||
const rfb = new RFB(target, getWebVNCUrl(containerName), {
|
||||
wsProtocols: ['binary', `clicd-vnc-ticket.${ticket}`],
|
||||
})
|
||||
rfb.scaleViewport = true
|
||||
rfb.resizeSession = false
|
||||
rfb.focusOnClick = true
|
||||
rfb.qualityLevel = 6
|
||||
rfb.compressionLevel = 2
|
||||
rfb.background = '#050505'
|
||||
rfb.addEventListener('connect', () => {
|
||||
setStatus('connected')
|
||||
})
|
||||
rfb.addEventListener('connect', () => setStatus('connected'))
|
||||
rfb.addEventListener('disconnect', (event) => {
|
||||
const detail = (event as CustomEvent<{ clean?: boolean }>).detail
|
||||
setStatus((current) => current === 'error' ? current : 'disconnected')
|
||||
@@ -76,7 +149,8 @@ export default function WebVNCViewer({ containerName, onClose }: WebVNCViewerPro
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
setStatus('error')
|
||||
setErrorMsg('WebVNC 初始化失败')
|
||||
const message = err instanceof Error && err.message ? `:${err.message}` : ''
|
||||
setErrorMsg(`WebVNC 初始化失败${message}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,35 +163,31 @@ export default function WebVNCViewer({ containerName, onClose }: WebVNCViewerPro
|
||||
}, [containerName])
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden h-full flex flex-col">
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-b border-gray-200 bg-gray-50 shrink-0">
|
||||
<div className="flex h-full flex-col overflow-hidden rounded-lg border border-gray-200 bg-white">
|
||||
<div className="flex shrink-0 items-center justify-between border-b border-gray-200 bg-gray-50 px-4 py-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Monitor className="w-4 h-4 text-gray-600" />
|
||||
<Monitor className="h-4 w-4 text-gray-600" />
|
||||
<span className="text-sm font-medium text-black">WebVNC - {containerName}</span>
|
||||
{status === 'connected' && <span className="text-xs px-1.5 py-0.5 rounded bg-green-100 text-green-700">已连接</span>}
|
||||
{status === 'connecting' && <span className="text-xs px-1.5 py-0.5 rounded bg-yellow-100 text-yellow-700">连接中...</span>}
|
||||
{status === 'disconnected' && <span className="text-xs px-1.5 py-0.5 rounded bg-gray-100 text-gray-600">已断开</span>}
|
||||
{status === 'error' && <span className="text-xs px-1.5 py-0.5 rounded bg-red-100 text-red-700">连接失败</span>}
|
||||
{status === 'connected' && <span className="rounded bg-green-100 px-1.5 py-0.5 text-xs text-green-700">已连接</span>}
|
||||
{status === 'connecting' && <span className="rounded bg-yellow-100 px-1.5 py-0.5 text-xs text-yellow-700">连接中...</span>}
|
||||
{status === 'disconnected' && <span className="rounded bg-gray-100 px-1.5 py-0.5 text-xs text-gray-600">已断开</span>}
|
||||
{status === 'error' && <span className="rounded bg-red-100 px-1.5 py-0.5 text-xs text-red-700">连接失败</span>}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => rfbRef.current?.sendCtrlAltDel()}
|
||||
className="inline-flex items-center gap-1 px-2 py-1.5 hover:bg-gray-200 rounded text-gray-500 text-xs"
|
||||
title="发送 Ctrl+Alt+Del"
|
||||
>
|
||||
<Send className="w-3.5 h-3.5" />
|
||||
<button onClick={() => rfbRef.current?.sendCtrlAltDel()} className="inline-flex items-center gap-1 rounded px-2 py-1.5 text-xs text-gray-500 hover:bg-gray-200" title="发送 Ctrl+Alt+Del">
|
||||
<Send className="h-3.5 w-3.5" />
|
||||
Ctrl+Alt+Del
|
||||
</button>
|
||||
<button onClick={connect} className="p-1.5 hover:bg-gray-200 rounded text-gray-500 text-xs" title="重新连接">
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
<button onClick={connect} className="rounded p-1.5 text-xs text-gray-500 hover:bg-gray-200" title="重新连接">
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button onClick={onClose} className="p-1.5 hover:bg-gray-200 rounded text-gray-500" title="关闭">
|
||||
<X className="w-4 h-4" />
|
||||
<button onClick={onClose} className="rounded p-1.5 text-gray-500 hover:bg-gray-200" title="关闭">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative flex-1 min-h-0 bg-black overflow-hidden">
|
||||
<div className="relative min-h-0 flex-1 overflow-hidden bg-black">
|
||||
<div ref={screenRef} className="h-full w-full [&>div]:h-full [&>div]:w-full [&_canvas]:block" />
|
||||
{(status === 'connecting' || status === 'error' || (status === 'disconnected' && errorMsg)) && (
|
||||
<div className={`absolute inset-x-0 bottom-0 border-t px-4 py-2 text-sm ${status === 'error' ? 'border-red-900 bg-red-950 text-red-100' : 'border-gray-800 bg-gray-950 text-gray-200'}`}>
|
||||
|
||||
Vendored
+1
-1
@@ -1,4 +1,4 @@
|
||||
declare module '@novnc/novnc' {
|
||||
declare module '@novnc/novnc/lib/rfb' {
|
||||
export default class RFB extends EventTarget {
|
||||
constructor(target: HTMLElement, url: string, options?: { credentials?: Record<string, string>; shared?: boolean; repeaterID?: string; wsProtocols?: string[] })
|
||||
scaleViewport: boolean
|
||||
|
||||
+1041
-214
File diff suppressed because it is too large
Load Diff
@@ -124,7 +124,7 @@ export default function ContainerDetail() {
|
||||
const vncFullscreenRef = useRef<HTMLDivElement>(null)
|
||||
const [vncFullscreen, setVncFullscreen] = useState(false)
|
||||
const [showNat, setShowNat] = useState(false)
|
||||
const [showNatAdd, setShowNatAdd] = useState(false)
|
||||
const [showMappingEditor, setShowMappingEditor] = useState(false)
|
||||
const [showExpiryEdit, setShowExpiryEdit] = useState(false)
|
||||
const [editExpiry, setEditExpiry] = useState('')
|
||||
const [savingExpiry, setSavingExpiry] = useState(false)
|
||||
@@ -144,6 +144,10 @@ export default function ContainerDetail() {
|
||||
const [resourceEdit, setResourceEdit] = useState({ vcpu: 1, ramMb: 512, ioMbps: 500, bwMbps: 100 })
|
||||
const [savingResource, setSavingResource] = useState(false)
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [showResetPassword, setShowResetPassword] = useState(false)
|
||||
const [resetPasswordDraft, setResetPasswordDraft] = useState('')
|
||||
const [resetPasswordResult, setResetPasswordResult] = useState('')
|
||||
const [resetPasswordSaving, setResetPasswordSaving] = useState(false)
|
||||
const [showSnapshots, setShowSnapshots] = useState(false)
|
||||
const [snapshots, setSnapshots] = useState<Snapshot[]>([])
|
||||
const [snapshotQuota, setSnapshotQuota] = useState(3)
|
||||
@@ -443,20 +447,58 @@ export default function ContainerDetail() {
|
||||
}
|
||||
}
|
||||
|
||||
const generateResetPassword = () => {
|
||||
const letters = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'
|
||||
const digits = '23456789'
|
||||
const symbols = '!@#$%*-_+='
|
||||
const all = letters + digits + symbols
|
||||
const pick = (chars: string) => chars[secureRandomInt(chars.length)]
|
||||
let password = pick(letters) + pick(digits)
|
||||
while (password.length < 16) password += pick(all)
|
||||
setResetPasswordDraft(secureShuffle(password.split('')).join(''))
|
||||
setResetPasswordResult('')
|
||||
}
|
||||
|
||||
const resetPasswordError = (password: string) => {
|
||||
if (password.length < 8 || password.length > 64) return '密码长度必须为 8-64 位'
|
||||
if (/\s/.test(password)) return '密码不能包含空白字符'
|
||||
if (!/[A-Za-z]/.test(password)) return '密码至少需要包含字母'
|
||||
if (!/\d/.test(password)) return '密码至少需要包含数字'
|
||||
return ''
|
||||
}
|
||||
|
||||
const handleResetPassword = async () => {
|
||||
if (!containerIdentifier || !(await dialog.confirm('重置密码', `确定要重置容器 ${container?.name} 的 SSH 密码吗?`))) return
|
||||
if (!containerIdentifier) return
|
||||
const password = resetPasswordDraft.trim()
|
||||
const validationError = resetPasswordError(password)
|
||||
if (validationError) {
|
||||
await dialog.alert('密码格式不正确', validationError)
|
||||
return
|
||||
}
|
||||
setResetPasswordSaving(true)
|
||||
try {
|
||||
const res = await resetSSHPassword(containerIdentifier)
|
||||
const res = await resetSSHPassword(containerIdentifier, password)
|
||||
if (res.data.success) {
|
||||
await dialog.alert('密码已重置', `新密码: ${(res.data.data as { password: string })?.password}`)
|
||||
const nextPassword = (res.data.data as { password: string })?.password || password
|
||||
setResetPasswordResult(nextPassword)
|
||||
setResetPasswordDraft(nextPassword)
|
||||
await fetchContainer()
|
||||
}
|
||||
} catch (err) {
|
||||
} catch (err: unknown) {
|
||||
console.error(err)
|
||||
dialog.alert('密码重置失败', '请稍后重试')
|
||||
const error = err as { response?: { data?: { message?: string } } }
|
||||
dialog.alert('密码重置失败', error.response?.data?.message || '请稍后重试')
|
||||
} finally {
|
||||
setResetPasswordSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const openResetPassword = () => {
|
||||
setResetPasswordDraft('')
|
||||
setResetPasswordResult('')
|
||||
setShowResetPassword(true)
|
||||
}
|
||||
|
||||
const handleAssignIPv6 = async () => {
|
||||
if (!containerIdentifier) return
|
||||
setActionLoading('ipv6')
|
||||
@@ -475,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,
|
||||
@@ -497,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> => {
|
||||
@@ -782,7 +816,7 @@ export default function ContainerDetail() {
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-14 h-14 bg-slate-100 rounded-lg flex items-center justify-center">
|
||||
<div className="w-14 h-14 flex items-center justify-center">
|
||||
{getTemplateIcon(container.template || '') || <Cpu className="w-7 h-7 text-slate-700" />}
|
||||
</div>
|
||||
<div>
|
||||
@@ -874,7 +908,18 @@ export default function ContainerDetail() {
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-5">
|
||||
<Panel title="连接信息">
|
||||
<Panel
|
||||
title="连接信息"
|
||||
extra={!isSubUser && !isWindows && !isSubUserPolicyBlocked ? (
|
||||
<button
|
||||
onClick={openResetPassword}
|
||||
className="inline-flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs text-gray-600 hover:bg-gray-100 hover:text-black"
|
||||
>
|
||||
<Key className="w-3.5 h-3.5" />
|
||||
重置 SSH 密码
|
||||
</button>
|
||||
) : undefined}
|
||||
>
|
||||
{isSubUserPolicyBlocked ? (
|
||||
<div className="rounded-md border border-red-100 bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||
虚拟机被策略临时封禁,连接信息暂不可用。
|
||||
@@ -925,12 +970,6 @@ export default function ContainerDetail() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!isSubUser && (
|
||||
<button onClick={handleResetPassword} className="inline-flex items-center gap-1.5 text-xs text-gray-600 hover:text-black">
|
||||
<Key className="w-3 h-3" />
|
||||
重置 SSH 密码
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Panel>
|
||||
@@ -1083,6 +1122,60 @@ export default function ContainerDetail() {
|
||||
|
||||
<ResourceStatsPanel range={range} onRangeChange={setRange} onRefresh={() => { fetchContainer(); fetchUsage() }} charts={charts} />
|
||||
|
||||
{showResetPassword && (
|
||||
<Modal title="重置 SSH 密码" onClose={() => setShowResetPassword(false)}>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">新 SSH 密码</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={resetPasswordDraft}
|
||||
onChange={(e) => { setResetPasswordDraft(e.target.value); setResetPasswordResult('') }}
|
||||
placeholder="请输入 8-64 位,至少包含字母和数字"
|
||||
className={inputClass}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={generateResetPassword}
|
||||
className="px-3 py-2 border border-gray-300 rounded-md text-gray-600 hover:bg-gray-50 hover:text-black"
|
||||
title="生成随机密码"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
{resetPasswordDraft && resetPasswordError(resetPasswordDraft) && (
|
||||
<p className="mt-1 text-xs text-red-600">{resetPasswordError(resetPasswordDraft)}</p>
|
||||
)}
|
||||
</div>
|
||||
{resetPasswordResult && (
|
||||
<div className="p-3 bg-green-50 border border-green-200 rounded-md">
|
||||
<div className="text-xs text-green-700 mb-1">密码已修改成功</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-mono text-sm text-green-900 break-all">{resetPasswordResult}</span>
|
||||
<button onClick={() => copyText(resetPasswordResult)} className="p-1 text-green-700 hover:text-green-900 rounded" title="复制">
|
||||
<Copy className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-gray-500 leading-relaxed">
|
||||
Linux LXC/KVM 修改 root SSH 密码通常无需重启;KVM 需要虚拟机运行且 guest agent 或 SSH 可用。
|
||||
</p>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<button onClick={() => setShowResetPassword(false)} className="px-4 py-2 text-sm text-gray-600 border border-gray-200 rounded-md hover:bg-gray-50">取消</button>
|
||||
<button
|
||||
onClick={handleResetPassword}
|
||||
disabled={resetPasswordSaving || !resetPasswordDraft || !!resetPasswordError(resetPasswordDraft)}
|
||||
className="px-4 py-2 text-sm bg-black text-white rounded-md hover:bg-gray-800 disabled:opacity-50"
|
||||
>
|
||||
{resetPasswordSaving ? '修改中...' : '确认修改'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{showSSH && (
|
||||
<Modal title={`WebSSH - ${container.name}`} onClose={() => setShowSSH(false)} wide>
|
||||
<div className="h-[70vh] min-h-[520px]">
|
||||
@@ -1293,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>
|
||||
)
|
||||
@@ -1310,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">
|
||||
@@ -1702,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>
|
||||
@@ -1953,6 +2093,32 @@ function TrafficBar({ container }: { container: Container }) {
|
||||
)
|
||||
}
|
||||
|
||||
function secureRandomInt(maxExclusive: number) {
|
||||
if (!Number.isSafeInteger(maxExclusive) || maxExclusive <= 0) {
|
||||
throw new Error('invalid random range')
|
||||
}
|
||||
const values = new Uint32Array(1)
|
||||
const maxUint32 = 0x100000000
|
||||
const limit = Math.floor(maxUint32 / maxExclusive) * maxExclusive
|
||||
let value = 0
|
||||
do {
|
||||
crypto.getRandomValues(values)
|
||||
value = values[0]
|
||||
} while (value >= limit)
|
||||
return value % maxExclusive
|
||||
}
|
||||
|
||||
function secureShuffle<T>(items: T[]) {
|
||||
const next = [...items]
|
||||
for (let i = next.length - 1; i > 0; i--) {
|
||||
const j = secureRandomInt(i + 1)
|
||||
const value = next[i]
|
||||
next[i] = next[j]
|
||||
next[j] = value
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
function getTemplateIcon(id: string): ReactNode {
|
||||
const size = 'w-6 h-6'
|
||||
id = id.startsWith('kvm-') ? id.slice(4) : id
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
import { ReactNode, useCallback, useEffect, useState } from 'react'
|
||||
import {
|
||||
Activity,
|
||||
CheckCircle2,
|
||||
Cpu,
|
||||
HardDrive,
|
||||
MemoryStick,
|
||||
RefreshCw,
|
||||
XCircle,
|
||||
} from 'lucide-react'
|
||||
import { getHostReport, HostProbeReport } from '../services/api'
|
||||
|
||||
export default function HostReport() {
|
||||
const [report, setReport] = useState<HostProbeReport | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const fetchReport = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await getHostReport()
|
||||
setReport(res.data.data || null)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchReport()
|
||||
}, [fetchReport])
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-black">宿主机信息</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">硬件、网络、磁盘健康与运行环境探测报告</p>
|
||||
</div>
|
||||
<button onClick={fetchReport} disabled={loading} className="inline-flex items-center gap-1.5 rounded-md border border-gray-200 px-3 py-2 text-sm text-gray-600 hover:bg-gray-50 disabled:opacity-50">
|
||||
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading && !report ? (
|
||||
<div className="rounded-lg border border-gray-200 bg-white py-14 text-center text-sm text-gray-400">正在探测宿主机环境...</div>
|
||||
) : !report ? (
|
||||
<div className="rounded-lg border border-gray-200 bg-white py-14 text-center text-sm text-gray-400">暂未获取到宿主机信息</div>
|
||||
) : (
|
||||
<div className="space-y-5">
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
<ProbeMetric icon={<Cpu className="h-4 w-4" />} label="CPU" value={report.cpu.model || 'Unknown'} sub={`${report.cpu.cores} 核 / ${report.cpu.threads} 线程`} />
|
||||
<ProbeMetric icon={<MemoryStick className="h-4 w-4" />} label="RAM" value={formatMB(report.memory.total_mb)} sub={`${formatMB(report.memory.used_mb)} 已用`} />
|
||||
<ProbeMetric icon={<HardDrive className="h-4 w-4" />} label="DISK" value={`${report.disks.length} 块硬盘`} sub={report.disks.map(d => d.type).filter(Boolean).join(' / ') || 'Unknown'} />
|
||||
<ProbeMetric icon={<Activity className="h-4 w-4" />} label="运行状态" value={report.system.uptime_text} sub={`${report.system.process_count} 个进程`} />
|
||||
</div>
|
||||
|
||||
<ProbeSection title="系统概览">
|
||||
<ProbeRows rows={[
|
||||
['主机名', report.hostname],
|
||||
['操作系统', report.os],
|
||||
['内核', report.kernel],
|
||||
['生成时间', report.generated_at],
|
||||
['CPU 架构', report.cpu.architecture],
|
||||
['CPU 虚拟化指令', report.cpu.virtualization ? `支持 (${report.cpu.virtualization_key})` : '未检测到'],
|
||||
['CPU 核显', report.cpu.has_integrated_gpu ? '检测到' : '未检测到'],
|
||||
['显卡', report.gpus.length ? `${report.gpus.length} 个` : '未检测到'],
|
||||
['运行能力', runtimeModeLabel(report.runtime.support_mode)],
|
||||
['KVM 嵌套虚拟化', `${report.runtime.nested_virtualization ? '支持' : '未检测到'} (${report.runtime.nested_detail || '-'})`],
|
||||
]} />
|
||||
</ProbeSection>
|
||||
|
||||
<ProbeSection title="公网与路由">
|
||||
<ProbeRows rows={[
|
||||
['公网 IPv4', report.public_ipv4.length ? report.public_ipv4.join('\n') : '未检测到'],
|
||||
['IPv4 地址', report.ipv4_addresses?.length ? report.ipv4_addresses.map(formatIPv4Address).join('\n') : '未检测到'],
|
||||
['IPv4 段', report.ipv4_prefixes?.length ? report.ipv4_prefixes.map(formatIPv4Prefix).join('\n') : '未检测到'],
|
||||
['IPv6 地址', report.ipv6_addresses.length ? report.ipv6_addresses.map(ip => `${ip.address}/${ip.prefix_len} (${ip.interface})`).join('\n') : '未检测到'],
|
||||
['IPv6 段', report.ipv6_prefixes?.length ? report.ipv6_prefixes.map(formatIPv6Prefix).join('\n') : '未检测到'],
|
||||
['网关', report.gateways.length ? report.gateways.map(g => `${g.family}: ${g.gateway || '-'} dev ${g.interface || '-'}`).join('\n') : '未检测到'],
|
||||
]} />
|
||||
</ProbeSection>
|
||||
|
||||
<ProbeTable
|
||||
title="内存条"
|
||||
empty="未检测到内存条明细,可能缺少 dmidecode 或权限受限"
|
||||
headers={['插槽', '容量', '类型', '频率', '厂商', '型号/序列号']}
|
||||
rows={(report.memory.modules || []).map(m => [
|
||||
m.locator || '-',
|
||||
m.size || '-',
|
||||
m.type || '-',
|
||||
m.speed || '-',
|
||||
m.manufacturer || '-',
|
||||
[m.part_number, m.serial_number].filter(Boolean).join(' / ') || '-',
|
||||
])}
|
||||
/>
|
||||
|
||||
<ProbeTable
|
||||
title="硬盘与健康"
|
||||
empty="未检测到硬盘"
|
||||
headers={['设备', '型号', '容量', '类型', '挂载点', '健康', '寿命', '通电', '读取', '写入', '命令数', '擦写']}
|
||||
rows={report.disks.map(d => [
|
||||
`${d.path || d.name}\n${d.serial || ''}`,
|
||||
d.model || '-',
|
||||
formatBytes(d.size_bytes),
|
||||
d.type || (d.rotational ? 'HDD' : 'SSD'),
|
||||
d.mountpoints?.length ? d.mountpoints.join('\n') : '-',
|
||||
`${diskHealthLabel(d.health)}\n${d.health_detail || ''}`,
|
||||
formatLifeUsed(d.smart?.life_used_percent),
|
||||
d.smart?.power_on_hours ? `${d.smart.power_on_hours} 小时\n${formatPowerOnDays(d.smart.power_on_hours)}` : '-',
|
||||
formatBytes(d.smart?.read_data_bytes || 0),
|
||||
formatBytes(d.smart?.written_data_bytes || 0),
|
||||
formatCommands(d.smart?.read_commands, d.smart?.write_commands),
|
||||
formatWear(d.smart?.wear_leveling_count, d.smart?.erase_count, d.smart?.power_cycle_count),
|
||||
])}
|
||||
/>
|
||||
|
||||
<ProbeTable
|
||||
title="网卡"
|
||||
empty="未检测到网卡"
|
||||
headers={['网卡', '状态', '驱动/速率', 'MAC', 'IPv4', 'IPv6']}
|
||||
rows={report.network_interfaces.map(n => [
|
||||
`${n.name}\n${n.model || ''}`,
|
||||
n.state || '-',
|
||||
`${n.driver || '-'}\n${n.speed_mbps > 0 ? `${n.speed_mbps} Mbps` : '-'}`,
|
||||
n.mac || '-',
|
||||
n.ipv4?.length ? n.ipv4.map(ip => `${ip.address}/${ip.prefix_len}`).join('\n') : '-',
|
||||
n.ipv6?.length ? n.ipv6.map(ip => `${ip.address}/${ip.prefix_len} ${ip.scope}`).join('\n') : '-',
|
||||
])}
|
||||
/>
|
||||
|
||||
<ProbeTable
|
||||
title="显卡"
|
||||
empty="未检测到显卡"
|
||||
headers={['名称', '厂商', '类型', '驱动']}
|
||||
rows={report.gpus.map(g => [g.name, g.vendor || '-', gpuTypeLabel(g.type), g.driver || '-'])}
|
||||
/>
|
||||
|
||||
<ProbeSection title="环境支持">
|
||||
<div className="grid gap-2 md:grid-cols-2">
|
||||
{report.environment.map(item => (
|
||||
<div key={item.key} className="flex items-start gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2">
|
||||
{item.ok ? <CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0 text-green-600" /> : <XCircle className={`mt-0.5 h-4 w-4 shrink-0 ${item.required ? 'text-red-600' : 'text-amber-600'}`} />}
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs font-medium text-gray-800">
|
||||
<span>{item.label}</span>
|
||||
<span className={`rounded px-1.5 py-0.5 text-[10px] ${item.required ? 'bg-gray-100 text-gray-600' : 'bg-blue-50 text-blue-700'}`}>
|
||||
{item.required ? '必要' : '可选'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 break-all font-mono text-[11px] text-gray-500">{item.detail || '-'}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ProbeSection>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ProbeMetric({ icon, label, value, sub }: { icon: ReactNode; label: string; value: string; sub: string }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-gray-200 bg-white px-3 py-3">
|
||||
<div className="mb-2 flex items-center gap-2 text-xs font-medium text-gray-500">
|
||||
{icon}
|
||||
{label}
|
||||
</div>
|
||||
<div className="line-clamp-2 break-words text-sm font-semibold text-gray-900" title={value}>{value}</div>
|
||||
<div className="mt-1 truncate text-xs text-gray-500" title={sub}>{sub}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ProbeSection({ title, children }: { title: string; children: ReactNode }) {
|
||||
return (
|
||||
<section>
|
||||
<h2 className="mb-2 text-sm font-semibold text-black">{title}</h2>
|
||||
{children}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function ProbeRows({ rows }: { rows: Array<[string, string]> }) {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-lg border border-gray-200 bg-white">
|
||||
{rows.map(([label, value]) => (
|
||||
<div key={label} className="grid gap-2 border-b border-gray-100 px-3 py-2 text-xs last:border-b-0 md:grid-cols-[160px_1fr]">
|
||||
<div className="font-medium text-gray-500">{label}</div>
|
||||
<div className="whitespace-pre-wrap break-words font-mono text-gray-800">{value || '-'}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ProbeTable({ title, headers, rows, empty }: { title: string; headers: string[]; rows: string[][]; empty: string }) {
|
||||
return (
|
||||
<section>
|
||||
<h2 className="mb-2 text-sm font-semibold text-black">{title}</h2>
|
||||
{rows.length === 0 ? (
|
||||
<div className="rounded-lg border border-gray-200 bg-white px-3 py-3 text-xs text-gray-400">{empty}</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-lg border border-gray-200 bg-white">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-100 bg-gray-50 text-left text-gray-500">
|
||||
{headers.map(header => <th key={header} className="px-3 py-2 font-medium">{header}</th>)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{rows.map((row, rowIndex) => (
|
||||
<tr key={rowIndex} className="align-top">
|
||||
{row.map((cell, cellIndex) => (
|
||||
<td key={cellIndex} className="max-w-[280px] whitespace-pre-wrap break-words px-3 py-2 text-gray-700">
|
||||
{cell || '-'}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function formatIPv4Address(ip: HostProbeReport['ipv4_addresses'][number]) {
|
||||
return `${ip.address}/${ip.prefix_len} (${ip.interface})`
|
||||
}
|
||||
|
||||
function formatIPv4Prefix(prefix: HostProbeReport['ipv4_prefixes'][number]) {
|
||||
const parts = [
|
||||
prefix.prefix || '-',
|
||||
prefix.subnet_mask ? `mask ${prefix.subnet_mask}` : '',
|
||||
prefix.gateway ? `via ${prefix.gateway}` : '',
|
||||
prefix.interface ? `dev ${prefix.interface}` : '',
|
||||
prefix.source ? `[${prefix.source}]` : '',
|
||||
].filter(Boolean)
|
||||
return parts.join(' ')
|
||||
}
|
||||
|
||||
function formatIPv6Prefix(prefix: HostProbeReport['ipv6_prefixes'][number]) {
|
||||
const value = prefix.prefix || prefix.address || '-'
|
||||
const cidr = value.includes('/') || !prefix.prefix_len ? value : `${value}/${prefix.prefix_len}`
|
||||
return `${cidr} via ${prefix.gateway || '-'}`
|
||||
}
|
||||
|
||||
function formatMB(value: number) {
|
||||
if (!value) return '-'
|
||||
if (value >= 1024) return `${(value / 1024).toFixed(1)} GB`
|
||||
return `${value} MB`
|
||||
}
|
||||
|
||||
function formatBytes(value: number) {
|
||||
if (!value) return '-'
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']
|
||||
let next = value
|
||||
let index = 0
|
||||
while (next >= 1024 && index < units.length - 1) {
|
||||
next /= 1024
|
||||
index++
|
||||
}
|
||||
return `${next.toFixed(index === 0 ? 0 : 1)} ${units[index]}`
|
||||
}
|
||||
|
||||
function formatLifeUsed(value?: number) {
|
||||
if (value === undefined || value === null) return '-'
|
||||
return `${value}% 已用\n${Math.max(0, 100 - value)}% 剩余`
|
||||
}
|
||||
|
||||
function formatPowerOnDays(hours: number) {
|
||||
const days = Math.floor(hours / 24)
|
||||
const rest = hours % 24
|
||||
return days > 0 ? `${days} 天 ${rest} 小时` : `${hours} 小时`
|
||||
}
|
||||
|
||||
function formatCommands(read?: number, write?: number) {
|
||||
if (!read && !write) return '-'
|
||||
return `读 ${formatCount(read || 0)}\n写 ${formatCount(write || 0)}`
|
||||
}
|
||||
|
||||
function formatCount(value: number) {
|
||||
if (!value) return '-'
|
||||
if (value >= 1_000_000_000) return `${(value / 1_000_000_000).toFixed(1)}B`
|
||||
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`
|
||||
if (value >= 1_000) return `${(value / 1_000).toFixed(1)}K`
|
||||
return `${value}`
|
||||
}
|
||||
|
||||
function formatWear(wear?: string, erase?: string, powerCycles?: number) {
|
||||
const rows: string[] = []
|
||||
if (wear) rows.push(`磨损 ${wear}`)
|
||||
if (erase) rows.push(`擦写 ${erase}`)
|
||||
if (powerCycles) rows.push(`启停 ${powerCycles}`)
|
||||
return rows.length ? rows.join('\n') : '-'
|
||||
}
|
||||
|
||||
function runtimeModeLabel(value: string) {
|
||||
switch (value) {
|
||||
case 'kvm_lxc':
|
||||
return '支持 KVM + LXC'
|
||||
case 'lxc_only':
|
||||
return '仅支持 LXC'
|
||||
default:
|
||||
return '未满足运行环境'
|
||||
}
|
||||
}
|
||||
|
||||
function diskHealthLabel(value: string) {
|
||||
switch (value) {
|
||||
case 'ok':
|
||||
return '健康'
|
||||
case 'failed':
|
||||
return '异常'
|
||||
default:
|
||||
return '未知'
|
||||
}
|
||||
}
|
||||
|
||||
function gpuTypeLabel(value: string) {
|
||||
if (value === 'integrated') return '核显'
|
||||
if (value === 'discrete') return '独显'
|
||||
return value || '-'
|
||||
}
|
||||
@@ -9,8 +9,9 @@ import {
|
||||
ToggleRight,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import { getImages, downloadImage, deleteImage, toggleImage, ImageInfo } from '../services/api'
|
||||
import { getImages, downloadImage, cancelImageDownload, deleteImage, toggleImage, ImageInfo } from '../services/api'
|
||||
import { useDialog } from '../components/Dialog'
|
||||
|
||||
export default function ImageManagement() {
|
||||
@@ -34,10 +35,14 @@ export default function ImageManagement() {
|
||||
|
||||
useEffect(() => {
|
||||
fetchImages()
|
||||
const interval = setInterval(fetchImages, 5000)
|
||||
return () => clearInterval(interval)
|
||||
}, [fetchImages])
|
||||
|
||||
useEffect(() => {
|
||||
const hasDownloads = images.some((img) => img.downloading)
|
||||
const interval = setInterval(fetchImages, hasDownloads ? 1500 : 5000)
|
||||
return () => clearInterval(interval)
|
||||
}, [fetchImages, images])
|
||||
|
||||
const handleDownload = async (templateId: string) => {
|
||||
setActionLoading(templateId)
|
||||
setError('')
|
||||
@@ -51,6 +56,19 @@ export default function ImageManagement() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancelDownload = async (templateId: string) => {
|
||||
setActionLoading(templateId)
|
||||
setError('')
|
||||
try {
|
||||
await cancelImageDownload(templateId)
|
||||
await fetchImages()
|
||||
} catch (err: unknown) {
|
||||
setError(apiErrorMessage(err, '取消失败'))
|
||||
} finally {
|
||||
setActionLoading(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (templateId: string) => {
|
||||
if (!(await dialog.confirm('删除镜像', '确定要删除该镜像缓存吗?删除后需要重新下载才能使用。'))) return
|
||||
setActionLoading(templateId)
|
||||
@@ -125,6 +143,7 @@ export default function ImageManagement() {
|
||||
downloadedCount={lxcImages.filter((img) => img.downloaded).length}
|
||||
totalCount={lxcImages.length}
|
||||
onDownload={handleDownload}
|
||||
onCancelDownload={handleCancelDownload}
|
||||
onDelete={handleDelete}
|
||||
onToggle={handleToggle}
|
||||
/>
|
||||
@@ -136,6 +155,7 @@ export default function ImageManagement() {
|
||||
downloadedCount={kvmImages.filter((img) => img.downloaded).length}
|
||||
totalCount={kvmImages.length}
|
||||
onDownload={handleDownload}
|
||||
onCancelDownload={handleCancelDownload}
|
||||
onDelete={handleDelete}
|
||||
onToggle={handleToggle}
|
||||
/>
|
||||
@@ -150,6 +170,7 @@ function ImageTable({
|
||||
downloadedCount,
|
||||
totalCount,
|
||||
onDownload,
|
||||
onCancelDownload,
|
||||
onDelete,
|
||||
onToggle,
|
||||
}: {
|
||||
@@ -159,6 +180,7 @@ function ImageTable({
|
||||
downloadedCount: number
|
||||
totalCount: number
|
||||
onDownload: (id: string) => void
|
||||
onCancelDownload: (id: string) => void
|
||||
onDelete: (id: string) => void
|
||||
onToggle: (id: string, enabled: boolean) => void
|
||||
}) {
|
||||
@@ -202,7 +224,7 @@ function ImageTable({
|
||||
<tr key={img.id} className="hover:bg-gray-50 transition-colors">
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-8 h-8 bg-gray-100 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<span className="w-8 h-8 flex items-center justify-center flex-shrink-0">
|
||||
{getTemplateIcon(img.id)}
|
||||
</span>
|
||||
<div>
|
||||
@@ -242,13 +264,18 @@ function ImageTable({
|
||||
)}
|
||||
|
||||
{img.downloading && (
|
||||
<span className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-amber-50 border border-amber-200 rounded-md text-amber-700 text-xs font-medium">
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
下载中...
|
||||
</span>
|
||||
<button
|
||||
onClick={() => onCancelDownload(img.id)}
|
||||
disabled={isBusy}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md border border-red-200 text-red-600 hover:bg-red-50 transition-colors text-xs font-medium disabled:opacity-50"
|
||||
title="取消下载并清理临时文件"
|
||||
>
|
||||
{isBusy ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <X className="w-3.5 h-3.5" />}
|
||||
{isBusy ? '取消中...' : '取消'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{img.downloaded && (
|
||||
{img.downloaded && !img.downloading && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => onToggle(img.id, img.enabled)}
|
||||
@@ -287,10 +314,33 @@ function ImageTable({
|
||||
|
||||
function StatusBadge({ img }: { img: ImageInfo }) {
|
||||
if (img.downloading) {
|
||||
const progress = Math.max(0, Math.min(100, img.progress || 0))
|
||||
const showProgress = img.stage === 'downloading' && progress > 0
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium bg-amber-50 text-amber-700">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse" />
|
||||
下载中
|
||||
<div className="inline-flex flex-col gap-1">
|
||||
<span
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium bg-amber-50 text-amber-700"
|
||||
title={downloadStatusTitle(img)}
|
||||
>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse" />
|
||||
{downloadStatusLabel(img)}
|
||||
</span>
|
||||
{showProgress && (
|
||||
<span className="block h-1 w-24 overflow-hidden rounded-full bg-amber-100">
|
||||
<span className="block h-full rounded-full bg-amber-500 transition-all" style={{ width: `${progress}%` }} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (img.error) {
|
||||
return (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium bg-red-50 text-red-600"
|
||||
title={img.error}
|
||||
>
|
||||
<AlertCircle className="w-3 h-3" />
|
||||
下载失败
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -318,6 +368,23 @@ function StatusBadge({ img }: { img: ImageInfo }) {
|
||||
)
|
||||
}
|
||||
|
||||
function downloadStatusLabel(img: ImageInfo) {
|
||||
if (img.stage === 'canceling') return '取消中'
|
||||
if (img.stage === 'converting') return '转换中'
|
||||
if (img.stage === 'lxc-create') return '下载中'
|
||||
if (img.progress > 0) return `下载中 ${Math.min(100, img.progress)}%`
|
||||
return '下载中'
|
||||
}
|
||||
|
||||
function downloadStatusTitle(img: ImageInfo) {
|
||||
const parts = [downloadStatusLabel(img)]
|
||||
if (img.stage) parts.push(`阶段:${img.stage}`)
|
||||
if (img.downloaded_bytes > 0 || img.total_bytes > 0) {
|
||||
parts.push(`${formatSize(img.downloaded_bytes)} / ${formatSize(img.total_bytes)}`)
|
||||
}
|
||||
return parts.join(',')
|
||||
}
|
||||
|
||||
function isWindowsImage(img: ImageInfo) {
|
||||
return img.distro === 'windows' || img.id.toLowerCase().includes('windows')
|
||||
}
|
||||
|
||||
@@ -3,13 +3,6 @@ import { Lock, User } from 'lucide-react'
|
||||
import AppIcon from '../components/AppIcon'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
|
||||
async function sha256Hash(input: string): Promise<string> {
|
||||
const msgBuffer = new TextEncoder().encode(input)
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer)
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer))
|
||||
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('')
|
||||
}
|
||||
|
||||
export default function Login() {
|
||||
const { login, accessCodeLogin } = useAuth()
|
||||
const [username, setUsername] = useState('')
|
||||
@@ -47,7 +40,7 @@ export default function Login() {
|
||||
<div className="w-full max-w-md">
|
||||
<div className="bg-white rounded-lg border border-gray-200 shadow-sm p-8">
|
||||
<div className="flex flex-col items-center mb-8">
|
||||
<div className="w-16 h-16 rounded-lg border border-gray-200 bg-gray-50 flex items-center justify-center mb-4">
|
||||
<div className="w-16 h-16 flex items-center justify-center mb-4">
|
||||
<AppIcon className="w-10 h-10" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-gray-950">CLICD</h1>
|
||||
@@ -113,7 +106,7 @@ export default function Login() {
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-xs text-gray-400 mt-6">CLICD v1.0.0</p>
|
||||
<p className="text-center text-xs text-gray-400 mt-6">CLICD v1.1.8</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
+332
-98
@@ -1,10 +1,13 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { UserCog, Key, LogIn, Monitor, Clock, Globe } from 'lucide-react'
|
||||
import { Dispatch, SetStateAction, useCallback, useEffect, useState } from 'react'
|
||||
import { Clock, Globe, Lock, LogIn, Monitor, RefreshCw, ShieldCheck, Upload, UserCog } from 'lucide-react'
|
||||
import {
|
||||
changePassword,
|
||||
changeUsername,
|
||||
getLoginLogs,
|
||||
getSSLSettings,
|
||||
LoginLog,
|
||||
SSLSettings,
|
||||
updateSSLSettings,
|
||||
} from '../services/api'
|
||||
import { useDialog } from '../components/Dialog'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
@@ -20,7 +23,16 @@ export default function Settings() {
|
||||
const [oldPwd, setOldPwd] = useState('')
|
||||
const [newPwd, setNewPwd] = useState('')
|
||||
const [newUsername, setNewUsername] = useState('')
|
||||
const [pwdForUser, setPwdForUser] = useState('')
|
||||
|
||||
const [ssl, setSSL] = useState<SSLSettings | null>(null)
|
||||
const [sslEnabled, setSSLEnabled] = useState(false)
|
||||
const [sslMode, setSSLMode] = useState<SSLSettings['mode']>('disabled')
|
||||
const [sslTarget, setSSLTarget] = useState('')
|
||||
const [sslEmail, setSSLEmail] = useState('')
|
||||
const [certPEM, setCertPEM] = useState('')
|
||||
const [keyPEM, setKeyPEM] = useState('')
|
||||
const [applyNow, setApplyNow] = useState(true)
|
||||
const [savingSSL, setSavingSSL] = useState(false)
|
||||
|
||||
const fetchLogs = useCallback(async () => {
|
||||
try {
|
||||
@@ -33,30 +45,95 @@ export default function Settings() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { fetchLogs(); const t = setInterval(fetchLogs, 15000); return () => clearInterval(t) }, [fetchLogs])
|
||||
const fetchSSL = useCallback(async () => {
|
||||
try {
|
||||
const res = await getSSLSettings()
|
||||
const data = res.data.data
|
||||
if (!data) return
|
||||
setSSL(data)
|
||||
setSSLEnabled(data.enabled)
|
||||
setSSLMode(data.mode || 'disabled')
|
||||
setSSLTarget(data.target || data.detected_host || '')
|
||||
setSSLEmail(data.email || '')
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchLogs()
|
||||
fetchSSL()
|
||||
const timer = setInterval(fetchLogs, 15000)
|
||||
return () => clearInterval(timer)
|
||||
}, [fetchLogs, fetchSSL])
|
||||
|
||||
const handleSSLModeChange = (mode: SSLSettings['mode']) => {
|
||||
setSSLMode(mode)
|
||||
const saved = ssl?.mode_certificates?.[mode]
|
||||
setSSLTarget(saved?.target || ssl?.detected_host || sslTarget)
|
||||
setSSLEmail(saved?.email || '')
|
||||
}
|
||||
|
||||
const handleSaveSSL = async () => {
|
||||
setSavingSSL(true)
|
||||
try {
|
||||
const enabled = sslEnabled && sslMode !== 'disabled'
|
||||
const res = await updateSSLSettings({
|
||||
enabled,
|
||||
mode: enabled ? sslMode : 'disabled',
|
||||
target: sslTarget,
|
||||
email: sslEmail,
|
||||
cert_pem: certPEM,
|
||||
key_pem: keyPEM,
|
||||
apply_now: applyNow,
|
||||
})
|
||||
if (res.data.data) {
|
||||
setSSL(res.data.data)
|
||||
setCertPEM('')
|
||||
setKeyPEM('')
|
||||
}
|
||||
dialog.alert('完成', applyNow ? 'SSL 设置已保存,服务正在重启。稍后请用新的协议重新打开面板。' : 'SSL 设置已保存,重启 clicd 服务后生效。')
|
||||
} catch (err: unknown) {
|
||||
const e = err as { response?: { data?: { message?: string } } }
|
||||
dialog.alert('失败', e.response?.data?.message || 'SSL 设置保存失败')
|
||||
} finally {
|
||||
setSavingSSL(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveAccount = async () => {
|
||||
if (!oldPwd) { dialog.alert('提示', '请输入当前密码以确认修改'); return }
|
||||
if (!newPwd && !newUsername) { dialog.alert('提示', '至少填写新密码或新用户名中的一项'); return }
|
||||
if (newPwd && newPwd.length < 6) { dialog.alert('提示', '新密码至少 6 位'); return }
|
||||
if (newUsername && newUsername.length < 3) { dialog.alert('提示', '用户名至少 3 位'); return }
|
||||
if (!oldPwd) {
|
||||
dialog.alert('提示', '请输入当前密码以确认修改')
|
||||
return
|
||||
}
|
||||
if (!newPwd && !newUsername) {
|
||||
dialog.alert('提示', '至少填写新密码或新用户名中的一项')
|
||||
return
|
||||
}
|
||||
if (newPwd && newPwd.length < 6) {
|
||||
dialog.alert('提示', '新密码至少 6 位')
|
||||
return
|
||||
}
|
||||
if (newUsername && newUsername.length < 3) {
|
||||
dialog.alert('提示', '用户名至少 3 位')
|
||||
return
|
||||
}
|
||||
|
||||
let results: string[] = []
|
||||
const results: string[] = []
|
||||
try {
|
||||
// 先改用户名(用旧密码验证),再改密码,否则改完密码后旧密码就失效了
|
||||
if (newUsername) {
|
||||
const res = await changeUsername(newUsername, oldPwd)
|
||||
if (res.data.success) results.push('用户名已修改')
|
||||
else results.push('用户名修改失败')
|
||||
results.push(res.data.success ? '用户名已修改' : '用户名修改失败')
|
||||
}
|
||||
if (newPwd) {
|
||||
const res = await changePassword(oldPwd, newPwd)
|
||||
if (res.data.success) results.push('密码已修改')
|
||||
else results.push('密码修改失败')
|
||||
results.push(res.data.success ? '密码已修改' : '密码修改失败')
|
||||
}
|
||||
if (results.length > 0) {
|
||||
dialog.alert('完成', results.join(',') + '。下次登录生效')
|
||||
setOldPwd(''); setNewPwd(''); setNewUsername('')
|
||||
dialog.alert('完成', `${results.join(',')}。下次登录生效`)
|
||||
setOldPwd('')
|
||||
setNewPwd('')
|
||||
setNewUsername('')
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const e = err as { response?: { data?: { message?: string } } }
|
||||
@@ -67,111 +144,268 @@ export default function Settings() {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-black"></div>
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-b-2 border-black"></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(logs.length / pageSize)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-black">面板设置</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">账号管理与登录日志</p>
|
||||
<p className="mt-1 text-sm text-gray-500">账号、安全证书与登录日志</p>
|
||||
</div>
|
||||
|
||||
{/* Account Settings */}
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-5">
|
||||
<h2 className="text-sm font-semibold text-black mb-4 flex items-center gap-2">
|
||||
<UserCog className="w-4 h-4" />账号设置
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">当前用户名</label>
|
||||
<input type="text" value={username || ''} disabled className="w-full px-3 py-2 border border-gray-200 rounded-md text-sm text-gray-400 bg-gray-50" />
|
||||
<div className="grid items-start gap-6 xl:grid-cols-[minmax(0,1.15fr)_minmax(360px,0.85fr)]">
|
||||
<SSLCard
|
||||
ssl={ssl}
|
||||
sslEnabled={sslEnabled}
|
||||
sslMode={sslMode}
|
||||
sslTarget={sslTarget}
|
||||
sslEmail={sslEmail}
|
||||
certPEM={certPEM}
|
||||
keyPEM={keyPEM}
|
||||
applyNow={applyNow}
|
||||
savingSSL={savingSSL}
|
||||
onRefresh={fetchSSL}
|
||||
onEnabledChange={setSSLEnabled}
|
||||
onModeChange={handleSSLModeChange}
|
||||
onTargetChange={setSSLTarget}
|
||||
onEmailChange={setSSLEmail}
|
||||
onCertChange={setCertPEM}
|
||||
onKeyChange={setKeyPEM}
|
||||
onApplyNowChange={setApplyNow}
|
||||
onSave={handleSaveSSL}
|
||||
/>
|
||||
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-5">
|
||||
<h2 className="mb-4 flex items-center gap-2 text-sm font-semibold text-black">
|
||||
<UserCog className="h-4 w-4" />账号设置
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-gray-500">当前用户名</label>
|
||||
<input type="text" value={username || ''} disabled className="w-full rounded-md border border-gray-200 bg-gray-50 px-3 py-2 text-sm text-gray-400" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-gray-500">新用户名,留空则不修改</label>
|
||||
<input type="text" value={newUsername} onChange={(e) => setNewUsername(e.target.value)} className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black" placeholder="至少 3 位" />
|
||||
</div>
|
||||
<div className="border-t border-gray-100 pt-3">
|
||||
<label className="mb-1 block text-xs text-gray-500">新密码,留空则不修改</label>
|
||||
<input type="password" value={newPwd} onChange={(e) => setNewPwd(e.target.value)} className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black" placeholder="至少 6 位" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-gray-500">当前密码,验证身份</label>
|
||||
<input type="password" value={oldPwd} onChange={(e) => setOldPwd(e.target.value)} className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black" placeholder="输入当前密码以确认修改" />
|
||||
</div>
|
||||
<button onClick={handleSaveAccount} className="w-full rounded-md bg-black px-4 py-2 text-sm text-white hover:bg-gray-800">保存修改</button>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">新用户名(留空则不修改)</label>
|
||||
<input type="text" value={newUsername} onChange={(e) => setNewUsername(e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm text-black bg-white" placeholder="至少 3 位" />
|
||||
</div>
|
||||
<div className="border-t border-gray-100 pt-3">
|
||||
<label className="block text-xs text-gray-500 mb-1">新密码(留空则不修改)</label>
|
||||
<input type="password" value={newPwd} onChange={(e) => setNewPwd(e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm text-black bg-white" placeholder="至少 6 位" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">当前密码(验证身份)</label>
|
||||
<input type="password" value={oldPwd} onChange={(e) => setOldPwd(e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm text-black bg-white" placeholder="输入当前密码以确认修改" />
|
||||
</div>
|
||||
<button onClick={handleSaveAccount} className="w-full px-4 py-2 bg-black text-white rounded-md text-sm hover:bg-gray-800">保存修改</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Login Logs */}
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-5">
|
||||
<h2 className="text-sm font-semibold text-black mb-4 flex items-center gap-2">
|
||||
<LogIn className="w-4 h-4" />登录日志
|
||||
<LoginLogCard logs={logs} logPage={logPage} pageSize={pageSize} totalPages={totalPages} setLogPage={setLogPage} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface SSLCardProps {
|
||||
ssl: SSLSettings | null
|
||||
sslEnabled: boolean
|
||||
sslMode: SSLSettings['mode']
|
||||
sslTarget: string
|
||||
sslEmail: string
|
||||
certPEM: string
|
||||
keyPEM: string
|
||||
applyNow: boolean
|
||||
savingSSL: boolean
|
||||
onRefresh: () => void
|
||||
onEnabledChange: (enabled: boolean) => void
|
||||
onModeChange: (mode: SSLSettings['mode']) => void
|
||||
onTargetChange: (target: string) => void
|
||||
onEmailChange: (email: string) => void
|
||||
onCertChange: (cert: string) => void
|
||||
onKeyChange: (key: string) => void
|
||||
onApplyNowChange: (apply: boolean) => void
|
||||
onSave: () => void
|
||||
}
|
||||
|
||||
function SSLCard(props: SSLCardProps) {
|
||||
const selectedSSL = props.ssl?.mode_certificates?.[props.sslMode]
|
||||
const modeOptions: Array<{ value: SSLSettings['mode']; label: string }> = [
|
||||
{ value: 'letsencrypt', label: 'Let’s Encrypt' },
|
||||
{ value: 'self_signed', label: '自签证书' },
|
||||
{ value: 'uploaded', label: '上传证书' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-5">
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<h2 className="flex items-center gap-2 text-sm font-semibold text-black">
|
||||
<ShieldCheck className="h-4 w-4" />SSL 证书
|
||||
</h2>
|
||||
{logs.length === 0 ? (
|
||||
<p className="text-sm text-gray-400">暂无登录记录</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="text-gray-400 border-b border-gray-100">
|
||||
<th className="text-left py-2 font-medium w-40"><span className="inline-flex items-center gap-1"><Clock className="w-3 h-3" />时间</span></th>
|
||||
<th className="text-left py-2 font-medium">用户名</th>
|
||||
<th className="text-left py-2 font-medium"><span className="inline-flex items-center gap-1"><Globe className="w-3 h-3" />IP</span></th>
|
||||
<th className="text-left py-2 font-medium"><span className="inline-flex items-center gap-1"><Monitor className="w-3 h-3" />设备</span></th>
|
||||
<th className="text-left py-2 font-medium">结果</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-50">
|
||||
{logs.slice((logPage - 1) * pageSize, logPage * pageSize).map((log, i) => (
|
||||
<tr key={i}>
|
||||
<td className="py-1.5 text-gray-500 font-mono whitespace-nowrap">{log.time}</td>
|
||||
<td className="py-1.5 text-gray-700">{log.username}</td>
|
||||
<td className="py-1.5 text-gray-500 font-mono">{log.ip}</td>
|
||||
<td className="py-1.5 text-gray-500 max-w-[180px] truncate" title={log.user_agent}>{formatUA(log.user_agent)}</td>
|
||||
<td className="py-1.5">
|
||||
<span className={`px-1.5 py-0.5 rounded text-xs ${log.success ? 'bg-gray-100 text-gray-700' : 'bg-red-50 text-red-600'}`}>
|
||||
{log.success ? '成功' : '失败'}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<button onClick={props.onRefresh} className="rounded-md border border-gray-200 p-1.5 text-gray-500 hover:bg-gray-50" title="刷新">
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<label className="flex items-center gap-2 text-sm text-gray-700">
|
||||
<input type="checkbox" checked={props.sslEnabled} onChange={(e) => props.onEnabledChange(e.target.checked)} className="h-4 w-4 rounded border-gray-300" />
|
||||
启用 HTTPS / WSS
|
||||
</label>
|
||||
|
||||
<div className="grid gap-2 sm:grid-cols-3">
|
||||
{modeOptions.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
onClick={() => props.onModeChange(option.value)}
|
||||
className={`rounded-md border px-3 py-2 text-sm ${props.sslMode === option.value ? 'border-black bg-black text-white' : 'border-gray-200 text-gray-700 hover:bg-gray-50'}`}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-gray-500">IP / 域名</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.sslTarget}
|
||||
onChange={(e) => props.onTargetChange(e.target.value)}
|
||||
className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black"
|
||||
placeholder={props.ssl?.detected_host || '服务器公网 IP 或域名'}
|
||||
/>
|
||||
</div>
|
||||
{props.sslMode === 'letsencrypt' && (
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-gray-500">邮箱,可选</label>
|
||||
<input type="email" value={props.sslEmail} onChange={(e) => props.onEmailChange(e.target.value)} className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black" placeholder="admin@example.com" />
|
||||
</div>
|
||||
{logs.length > pageSize && (
|
||||
<div className="flex items-center justify-between mt-3 pt-3 border-t border-gray-100">
|
||||
<span className="text-xs text-gray-400">共 {logs.length} 条,第 {logPage}/{Math.ceil(logs.length / pageSize)} 页</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<button onClick={() => setLogPage(1)} disabled={logPage === 1} className="px-2 py-1 text-xs border border-gray-200 rounded hover:bg-gray-50 disabled:opacity-30">首页</button>
|
||||
<button onClick={() => setLogPage(p => Math.max(1, p - 1))} disabled={logPage === 1} className="px-2 py-1 text-xs border border-gray-200 rounded hover:bg-gray-50 disabled:opacity-30">上一页</button>
|
||||
{Array.from({length: Math.min(5, Math.ceil(logs.length / pageSize))}, (_, i) => {
|
||||
const totalPages = Math.ceil(logs.length / pageSize)
|
||||
let start = Math.max(1, logPage - 2)
|
||||
if (start + 4 > totalPages) start = Math.max(1, totalPages - 4)
|
||||
const page = start + i
|
||||
if (page > totalPages) return null
|
||||
return (
|
||||
<button key={page} onClick={() => setLogPage(page)} className={`w-7 h-7 text-xs rounded ${page === logPage ? 'bg-black text-white' : 'border border-gray-200 hover:bg-gray-50'}`}>{page}</button>
|
||||
)
|
||||
})}
|
||||
<button onClick={() => setLogPage(p => Math.min(Math.ceil(logs.length / pageSize), p + 1))} disabled={logPage >= Math.ceil(logs.length / pageSize)} className="px-2 py-1 text-xs border border-gray-200 rounded hover:bg-gray-50 disabled:opacity-30">下一页</button>
|
||||
<button onClick={() => setLogPage(Math.ceil(logs.length / pageSize))} disabled={logPage >= Math.ceil(logs.length / pageSize)} className="px-2 py-1 text-xs border border-gray-200 rounded hover:bg-gray-50 disabled:opacity-30">末页</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{props.sslMode === 'letsencrypt' && (
|
||||
<div className="rounded-md border border-amber-200 bg-amber-50 p-3 text-xs text-amber-800">
|
||||
纯 IP 证书需要服务器安装 Certbot 5.4+,且验证时 80 端口必须能被 Let’s Encrypt 访问。IP 证书是短有效期证书,certbot 需要保持自动续签。
|
||||
</div>
|
||||
)}
|
||||
|
||||
{props.sslMode === 'self_signed' && (
|
||||
<div className="rounded-md border border-gray-100 bg-gray-50 p-3 text-xs text-gray-600">
|
||||
自签证书可以加密面板和 VNC,但浏览器会提示证书不受信任;证书快到期时系统会自动重新签发。
|
||||
</div>
|
||||
)}
|
||||
|
||||
{props.sslMode === 'uploaded' && (
|
||||
<div className="grid gap-3 lg:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-gray-500">证书 PEM / fullchain.pem</label>
|
||||
<textarea value={props.certPEM} onChange={(e) => props.onCertChange(e.target.value)} rows={7} className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 font-mono text-xs text-black" placeholder="-----BEGIN CERTIFICATE-----" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-gray-500">私钥 PEM / privkey.pem</label>
|
||||
<textarea value={props.keyPEM} onChange={(e) => props.onKeyChange(e.target.value)} rows={7} className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 font-mono text-xs text-black" placeholder="-----BEGIN PRIVATE KEY-----" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedSSL?.certificate ? (
|
||||
<div className="rounded-md border border-gray-100 bg-gray-50 p-3 text-xs text-gray-600">
|
||||
<div className="flex items-center gap-2 text-gray-800">
|
||||
<Lock className="h-3.5 w-3.5" />
|
||||
当前证书:{selectedSSL.certificate.valid ? '有效' : '已过期或未生效'}
|
||||
</div>
|
||||
<div className="mt-1 font-mono">到期时间:{selectedSSL.certificate.not_after}</div>
|
||||
<div className="mt-1 truncate font-mono" title={selectedSSL.cert_path}>证书路径:{selectedSSL.cert_path || '-'}</div>
|
||||
{selectedSSL.last_error && <div className="mt-1 text-red-600">最近错误:{selectedSSL.last_error}</div>}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border border-gray-100 bg-gray-50 p-3 text-xs text-gray-600">
|
||||
{props.sslMode === 'uploaded' ? '上传来源还没有保存证书,请粘贴证书和私钥后保存。' : '当前来源还没有保存证书,保存 SSL 设置时会自动生成或申请。'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label className="flex items-center gap-2 text-xs text-gray-500">
|
||||
<input type="checkbox" checked={props.applyNow} onChange={(e) => props.onApplyNowChange(e.target.checked)} className="h-4 w-4 rounded border-gray-300" />
|
||||
保存后自动重启服务并立即生效
|
||||
</label>
|
||||
|
||||
<button onClick={props.onSave} disabled={props.savingSSL} className="inline-flex w-full items-center justify-center gap-2 rounded-md bg-black px-4 py-2 text-sm text-white hover:bg-gray-800 disabled:opacity-50">
|
||||
<Upload className="h-4 w-4" />
|
||||
{props.savingSSL ? '保存中...' : '保存 SSL 设置'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface LoginLogCardProps {
|
||||
logs: LoginLog[]
|
||||
logPage: number
|
||||
pageSize: number
|
||||
totalPages: number
|
||||
setLogPage: Dispatch<SetStateAction<number>>
|
||||
}
|
||||
|
||||
function LoginLogCard({ logs, logPage, pageSize, totalPages, setLogPage }: LoginLogCardProps) {
|
||||
return (
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-5">
|
||||
<h2 className="mb-4 flex items-center gap-2 text-sm font-semibold text-black">
|
||||
<LogIn className="h-4 w-4" />登录日志
|
||||
</h2>
|
||||
{logs.length === 0 ? (
|
||||
<p className="text-sm text-gray-400">暂无登录记录</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-100 text-gray-400">
|
||||
<th className="w-40 py-2 text-left font-medium"><span className="inline-flex items-center gap-1"><Clock className="h-3 w-3" />时间</span></th>
|
||||
<th className="py-2 text-left font-medium">用户名</th>
|
||||
<th className="py-2 text-left font-medium"><span className="inline-flex items-center gap-1"><Globe className="h-3 w-3" />IP</span></th>
|
||||
<th className="py-2 text-left font-medium"><span className="inline-flex items-center gap-1"><Monitor className="h-3 w-3" />设备</span></th>
|
||||
<th className="py-2 text-left font-medium">结果</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-50">
|
||||
{logs.slice((logPage - 1) * pageSize, logPage * pageSize).map((log, index) => (
|
||||
<tr key={`${log.time}-${index}`}>
|
||||
<td className="whitespace-nowrap py-1.5 font-mono text-gray-500">{log.time}</td>
|
||||
<td className="py-1.5 text-gray-700">{log.username}</td>
|
||||
<td className="py-1.5 font-mono text-gray-500">{log.ip}</td>
|
||||
<td className="max-w-[180px] truncate py-1.5 text-gray-500" title={log.user_agent}>{formatUA(log.user_agent)}</td>
|
||||
<td className="py-1.5">
|
||||
<span className={`rounded px-1.5 py-0.5 text-xs ${log.success ? 'bg-gray-100 text-gray-700' : 'bg-red-50 text-red-600'}`}>
|
||||
{log.success ? '成功' : '失败'}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{logs.length > pageSize && (
|
||||
<div className="mt-3 flex items-center justify-between border-t border-gray-100 pt-3">
|
||||
<span className="text-xs text-gray-400">共 {logs.length} 条,第 {logPage}/{totalPages} 页</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<button onClick={() => setLogPage(1)} disabled={logPage === 1} className="rounded border border-gray-200 px-2 py-1 text-xs hover:bg-gray-50 disabled:opacity-30">首页</button>
|
||||
<button onClick={() => setLogPage(p => Math.max(1, p - 1))} disabled={logPage === 1} className="rounded border border-gray-200 px-2 py-1 text-xs hover:bg-gray-50 disabled:opacity-30">上一页</button>
|
||||
<button onClick={() => setLogPage(p => Math.min(totalPages, p + 1))} disabled={logPage >= totalPages} className="rounded border border-gray-200 px-2 py-1 text-xs hover:bg-gray-50 disabled:opacity-30">下一页</button>
|
||||
<button onClick={() => setLogPage(totalPages)} disabled={logPage >= totalPages} className="rounded border border-gray-200 px-2 py-1 text-xs hover:bg-gray-50 disabled:opacity-30">末页</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatUA(ua: string): string {
|
||||
// Extract browser/OS info from UA string
|
||||
const parts: string[] = []
|
||||
if (ua.includes('Windows NT')) parts.push('Windows')
|
||||
else if (ua.includes('Mac OS X')) parts.push('macOS')
|
||||
|
||||
@@ -94,6 +94,7 @@ export interface Template {
|
||||
release: string
|
||||
arch: string
|
||||
variant?: string
|
||||
desktop?: string
|
||||
description: string
|
||||
}
|
||||
|
||||
@@ -135,6 +136,16 @@ export interface IPv6Status {
|
||||
prefixes: IPv6PrefixInfo[]
|
||||
}
|
||||
|
||||
export interface IPv4PrefixInfo {
|
||||
interface: string
|
||||
address: string
|
||||
prefix: string
|
||||
prefix_len: number
|
||||
subnet_mask: string
|
||||
gateway: string
|
||||
source: string
|
||||
}
|
||||
|
||||
export interface DashboardStats {
|
||||
total_containers: number
|
||||
running: number
|
||||
@@ -160,6 +171,93 @@ export interface HostInfo {
|
||||
load: { load1: number; load5: number; load15: number }
|
||||
}
|
||||
|
||||
export interface HostProbeReport {
|
||||
generated_at: string
|
||||
hostname: string
|
||||
kernel: string
|
||||
os: string
|
||||
cpu: {
|
||||
model: string
|
||||
cores: number
|
||||
threads: number
|
||||
architecture: string
|
||||
flags: string[]
|
||||
has_integrated_gpu: boolean
|
||||
virtualization: boolean
|
||||
virtualization_key: string
|
||||
}
|
||||
memory: {
|
||||
total_mb: number
|
||||
used_mb: number
|
||||
free_mb: number
|
||||
modules: Array<{
|
||||
locator: string
|
||||
size: string
|
||||
type: string
|
||||
speed: string
|
||||
manufacturer: string
|
||||
part_number: string
|
||||
serial_number: string
|
||||
}>
|
||||
}
|
||||
disks: Array<{
|
||||
name: string
|
||||
path: string
|
||||
model: string
|
||||
serial: string
|
||||
size_bytes: number
|
||||
type: string
|
||||
rotational: boolean
|
||||
mountpoints: string[]
|
||||
health: string
|
||||
health_detail: string
|
||||
smart?: {
|
||||
available: boolean
|
||||
life_used_percent?: number
|
||||
power_on_hours?: number
|
||||
power_cycle_count?: number
|
||||
read_data_bytes?: number
|
||||
written_data_bytes?: number
|
||||
read_commands?: number
|
||||
write_commands?: number
|
||||
wear_leveling_count?: string
|
||||
erase_count?: string
|
||||
media_errors?: number
|
||||
}
|
||||
}>
|
||||
network_interfaces: Array<{
|
||||
name: string
|
||||
mac: string
|
||||
state: string
|
||||
speed_mbps: number
|
||||
driver: string
|
||||
model: string
|
||||
ipv4: Array<{ interface: string; address: string; prefix_len: number; scope: string; gateway?: string }>
|
||||
ipv6: Array<{ interface: string; address: string; prefix_len: number; scope: string; gateway?: string }>
|
||||
}>
|
||||
public_ipv4: string[]
|
||||
ipv4_addresses: Array<{ interface: string; address: string; prefix_len: number; scope: string; gateway?: string }>
|
||||
ipv4_prefixes: IPv4PrefixInfo[]
|
||||
ipv6_addresses: Array<{ interface: string; address: string; prefix_len: number; scope: string; gateway?: string }>
|
||||
ipv6_prefixes: IPv6PrefixInfo[]
|
||||
gateways: Array<{ family: string; interface: string; gateway: string }>
|
||||
gpus: Array<{ name: string; vendor: string; driver: string; type: string }>
|
||||
runtime: {
|
||||
lxc_available: boolean
|
||||
kvm_available: boolean
|
||||
dev_kvm: boolean
|
||||
nested_virtualization: boolean
|
||||
nested_detail: string
|
||||
support_mode: string
|
||||
}
|
||||
system: {
|
||||
uptime_seconds: number
|
||||
uptime_text: string
|
||||
process_count: number
|
||||
}
|
||||
environment: Array<{ key: string; label: string; ok: boolean; required: boolean; detail: string }>
|
||||
}
|
||||
|
||||
export interface ContainerUsage {
|
||||
memory_usage_bytes: number
|
||||
memory_total_bytes?: number
|
||||
@@ -219,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')
|
||||
@@ -244,8 +383,8 @@ export const restartContainer = (id: ContainerIdentifier) =>
|
||||
export const reinstallContainer = (id: ContainerIdentifier, templateId: string) =>
|
||||
api.post<APIResponse>(`/containers/${id}/reinstall`, { template_id: templateId })
|
||||
|
||||
export const resetSSHPassword = (id: ContainerIdentifier) =>
|
||||
api.post<APIResponse<{ password: string }>>(`/containers/${id}/reset-password`)
|
||||
export const resetSSHPassword = (id: ContainerIdentifier, password?: string) =>
|
||||
api.post<APIResponse<{ password: string }>>(`/containers/${id}/reset-password`, password ? { password } : {})
|
||||
|
||||
export const getContainerUsage = (id: ContainerIdentifier) =>
|
||||
api.get<APIResponse<ContainerUsage>>(`/containers/${id}/usage`)
|
||||
@@ -357,15 +496,24 @@ export interface ImageInfo {
|
||||
downloaded: boolean
|
||||
enabled: boolean
|
||||
downloading: boolean
|
||||
progress: number
|
||||
downloaded_bytes: number
|
||||
total_bytes: number
|
||||
stage?: string
|
||||
error?: string
|
||||
size_bytes: number
|
||||
manual_path?: string
|
||||
desktop?: string
|
||||
}
|
||||
|
||||
export const getImages = () =>
|
||||
api.get<APIResponse<ImageInfo[]>>('/images')
|
||||
|
||||
export const downloadImage = (templateId: string) =>
|
||||
api.post<APIResponse>('/images/download', { template_id: templateId }, { timeout: 1800000 }) // 30min timeout
|
||||
api.post<APIResponse>('/images/download', { template_id: templateId })
|
||||
|
||||
export const cancelImageDownload = (templateId: string) =>
|
||||
api.post<APIResponse>('/images/cancel', { template_id: templateId })
|
||||
|
||||
export const deleteImage = (templateId: string) =>
|
||||
api.delete<APIResponse>('/images/delete', { data: { template_id: templateId } })
|
||||
@@ -383,6 +531,9 @@ export const getDashboard = () =>
|
||||
export const getHostInfo = () =>
|
||||
api.get<APIResponse<HostInfo>>('/host-info')
|
||||
|
||||
export const getHostReport = () =>
|
||||
api.get<APIResponse<HostProbeReport>>('/host-report')
|
||||
|
||||
// Snapshots
|
||||
export interface Snapshot {
|
||||
id: string
|
||||
@@ -446,10 +597,9 @@ export const getWebSSHUrl = (containerName: string) => {
|
||||
return `${protocol}//${window.location.host}/api/ssh?${params.toString()}`
|
||||
}
|
||||
|
||||
export const getWebVNCUrl = (containerName: string, ticket?: string) => {
|
||||
export const getWebVNCUrl = (containerName: string) => {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
const params = new URLSearchParams({ container: containerName })
|
||||
if (ticket) params.set('ticket', ticket)
|
||||
return `${protocol}//${window.location.host}/api/vnc?${params.toString()}`
|
||||
}
|
||||
|
||||
|
||||
+648
-103
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user