mirror of
https://github.com/MengMengCode/CLICD.git
synced 2026-08-05 05:36:07 +08:00
Compare commits
50 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d83a5e3473 | |||
| 18c9d75a05 | |||
| b80e4817ef | |||
| 0b052f2217 | |||
| 80386d35ba | |||
| a45e063fc2 | |||
| e71adf6830 | |||
| bb0c4f999d | |||
| 0c9f420474 | |||
| 9a826add87 | |||
| 63611dc932 | |||
| e6551bf4ae | |||
| 5bf2b6534a | |||
| 917afc3157 | |||
| c8081edbac | |||
| 95eb00a31d | |||
| f4edf94800 | |||
| 82b42e7961 | |||
| e971d99070 | |||
| 8dd09ff009 | |||
| 7f4755788a | |||
| c24df1d42f | |||
| cf00d0d03d | |||
| 79be2d5cbd | |||
| e66327db29 | |||
| 2b4fe4f5bc | |||
| a923daa7a2 | |||
| c46f84c66e | |||
| 835bb51c6e | |||
| 7aed51e86b | |||
| e364807fb9 | |||
| c63ce02709 | |||
| 54f9ed7f7d | |||
| b01f9fe301 | |||
| d03e2c4c0c | |||
| 33603f5776 | |||
| 9ad7bcc97a | |||
| f3a1687a18 | |||
| 49b13af91c | |||
| e79609281f | |||
| 2fa130a2b6 | |||
| 14d2192b05 | |||
| 9f5ad94a83 | |||
| ac6587f2bc | |||
| 6fad37b844 | |||
| d0eb92eaab | |||
| 5207082cd1 | |||
| 608b50f18a | |||
| b58a6b1030 | |||
| 366f889a8c |
Submodule .claude/worktrees/agent-ae3871aebda20eb86 deleted from 422e48b524
@@ -0,0 +1,15 @@
|
|||||||
|
# These are supported funding model platforms
|
||||||
|
|
||||||
|
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
|
||||||
|
patreon: # Replace with a single Patreon username
|
||||||
|
open_collective: # Replace with a single Open Collective username
|
||||||
|
ko_fi: # Replace with a single Ko-fi username
|
||||||
|
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||||
|
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||||
|
liberapay: # Replace with a single Liberapay username
|
||||||
|
issuehunt: # Replace with a single IssueHunt username
|
||||||
|
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
|
||||||
|
polar: # Replace with a single Polar username
|
||||||
|
buy_me_a_coffee: mengmengcode
|
||||||
|
thanks_dev: # Replace with a single thanks.dev username
|
||||||
|
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
||||||
+37
-13
@@ -6,7 +6,7 @@ on:
|
|||||||
- main
|
- main
|
||||||
- master
|
- master
|
||||||
tags:
|
tags:
|
||||||
- "v*"
|
- 'v*'
|
||||||
pull_request:
|
pull_request:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
@@ -25,31 +25,39 @@ jobs:
|
|||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "20"
|
node-version: 20
|
||||||
cache: npm
|
cache: npm
|
||||||
cache-dependency-path: frontend/package-lock.json
|
cache-dependency-path: |
|
||||||
|
frontend/package-lock.json
|
||||||
|
docs/package-lock.json
|
||||||
|
|
||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: "1.22.x"
|
go-version: '1.24.5'
|
||||||
cache-dependency-path: backend/go.sum
|
cache-dependency-path: backend/go.sum
|
||||||
|
|
||||||
|
- name: Install frontend dependencies
|
||||||
|
run: npm ci --prefix frontend
|
||||||
|
|
||||||
|
- name: Install docs dependencies
|
||||||
|
run: npm ci --prefix docs
|
||||||
|
|
||||||
|
- name: Build docs
|
||||||
|
run: npm run build --prefix docs
|
||||||
|
|
||||||
- name: Set version from tag
|
- name: Set version from tag
|
||||||
shell: bash
|
|
||||||
run: |
|
run: |
|
||||||
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
|
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
|
||||||
echo "CLICD_VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV
|
echo "CLICD_VERSION=${GITHUB_REF#refs/tags/v}" >> "$GITHUB_ENV"
|
||||||
else
|
else
|
||||||
echo "CLICD_VERSION=dev" >> $GITHUB_ENV
|
echo "CLICD_VERSION=dev" >> "$GITHUB_ENV"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Build
|
- name: Build CLICD
|
||||||
shell: bash
|
|
||||||
run: bash build.sh
|
run: bash build.sh
|
||||||
|
|
||||||
- name: Package
|
- name: Package CLICD
|
||||||
shell: bash
|
|
||||||
run: |
|
run: |
|
||||||
mkdir -p dist package/clicd-linux-amd64
|
mkdir -p dist package/clicd-linux-amd64
|
||||||
cp build/clicd package/clicd-linux-amd64/clicd
|
cp build/clicd package/clicd-linux-amd64/clicd
|
||||||
@@ -57,7 +65,24 @@ jobs:
|
|||||||
chmod +x package/clicd-linux-amd64/clicd package/clicd-linux-amd64/install.sh
|
chmod +x package/clicd-linux-amd64/clicd package/clicd-linux-amd64/install.sh
|
||||||
tar -C package -czf dist/clicd-linux-amd64.tar.gz clicd-linux-amd64
|
tar -C package -czf dist/clicd-linux-amd64.tar.gz clicd-linux-amd64
|
||||||
cp build/clicd dist/clicd-linux-amd64
|
cp build/clicd dist/clicd-linux-amd64
|
||||||
sha256sum dist/* > dist/SHA256SUMS
|
|
||||||
|
- name: Package Mofang module
|
||||||
|
run: |
|
||||||
|
if [ ! -f Mofang/clicd.php ]; then
|
||||||
|
echo "Mofang module not present; skipping package."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v zip >/dev/null 2>&1; then
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y zip
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd Mofang
|
||||||
|
zip -r ../dist/clicd-mofang.zip clicd.php handlers templates -x '*.DS_Store' -x '*/.DS_Store'
|
||||||
|
|
||||||
|
- name: Generate checksums
|
||||||
|
run: sha256sum dist/* > dist/SHA256SUMS
|
||||||
|
|
||||||
- name: Upload artifact
|
- name: Upload artifact
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
@@ -69,7 +94,6 @@ jobs:
|
|||||||
if: startsWith(github.ref, 'refs/tags/v')
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ github.token }}
|
GH_TOKEN: ${{ github.token }}
|
||||||
shell: bash
|
|
||||||
run: |
|
run: |
|
||||||
gh release create "$GITHUB_REF_NAME" dist/* --generate-notes || \
|
gh release create "$GITHUB_REF_NAME" dist/* --generate-notes || \
|
||||||
gh release upload "$GITHUB_REF_NAME" dist/* --clobber
|
gh release upload "$GITHUB_REF_NAME" dist/* --clobber
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -13,6 +13,7 @@ backend/internal/server/web/*
|
|||||||
|
|
||||||
# Build artifacts
|
# Build artifacts
|
||||||
/build/
|
/build/
|
||||||
|
Mofang/*.zip
|
||||||
*.exe
|
*.exe
|
||||||
*.dll
|
*.dll
|
||||||
*.so
|
*.so
|
||||||
@@ -58,6 +59,7 @@ backend/tmp/
|
|||||||
*.swp
|
*.swp
|
||||||
*.swo
|
*.swo
|
||||||
*~
|
*~
|
||||||
|
.claude/
|
||||||
|
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.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.
|
||||||
@@ -0,0 +1,375 @@
|
|||||||
|
# CLICD 魔方财务对接模块
|
||||||
|
|
||||||
|
这是用于智简魔方 / IDCSMART 的 CLICD 服务器模块。模块通过 CLICD API 完成实例开通、删除、开关机、重启、重装、改密、资源变更、流量重置、NAT 端口映射管理、实例信息展示和 WebSSH 入口。
|
||||||
|
|
||||||
|
## 文件结构
|
||||||
|
|
||||||
|
```text
|
||||||
|
clicd.php
|
||||||
|
README.md
|
||||||
|
handlers/
|
||||||
|
webssh.php
|
||||||
|
templates/
|
||||||
|
info.html
|
||||||
|
nat.html
|
||||||
|
```
|
||||||
|
|
||||||
|
安装时请保持目录结构不变,将整个 `clicd` 目录放入魔方服务器模块目录:
|
||||||
|
|
||||||
|
```text
|
||||||
|
public/plugins/servers/clicd/
|
||||||
|
```
|
||||||
|
|
||||||
|
## 服务器配置
|
||||||
|
|
||||||
|
在魔方后台添加服务器时,模块名称选择 `clicd`。
|
||||||
|
|
||||||
|
CLICD 面板地址建议使用 HTTPS:
|
||||||
|
|
||||||
|
```text
|
||||||
|
主机名 = https://0.0.0.0:8999
|
||||||
|
```
|
||||||
|
|
||||||
|
也可以拆分填写:
|
||||||
|
|
||||||
|
```text
|
||||||
|
IP地址 = 0.0.0.0
|
||||||
|
端口 = 8999
|
||||||
|
secure = 开启
|
||||||
|
```
|
||||||
|
|
||||||
|
API Key 可以填写在以下任意一个字段中:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Hash
|
||||||
|
密码
|
||||||
|
```
|
||||||
|
|
||||||
|
模块请求 CLICD 时会同时携带:
|
||||||
|
|
||||||
|
```text
|
||||||
|
X-API-Key: clicd_sk_xxxx
|
||||||
|
Authorization: Bearer clicd_sk_xxxx
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
## 产品配置项
|
||||||
|
|
||||||
|
| 字段 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| `virtualization` | 虚拟化类型,`lxc` 或 `kvm` |
|
||||||
|
| `template_id` | CLICD 模板 / 镜像 ID |
|
||||||
|
| `vcpu` | CPU 核心数 |
|
||||||
|
| `cpu_percent` | CPU 使用率限制,`0` 表示不额外限制 |
|
||||||
|
| `ram_mb` | 内存,单位 MB |
|
||||||
|
| `disk_gb` | 系统盘,单位 GB |
|
||||||
|
| `network_bw_mbps` | 带宽,单位 Mbps |
|
||||||
|
| `traffic_mode` | `total` 总流量,或 `in_out` 入 / 出分开 |
|
||||||
|
| `monthly_traffic_gb` | 月流量 GB |
|
||||||
|
| `traffic_in_gb` | 入站流量 GB,`in_out` 模式使用 |
|
||||||
|
| `traffic_out_gb` | 出站流量 GB,`in_out` 模式使用 |
|
||||||
|
| `io_speed_mbps` | 磁盘 IO 限制,`0` 表示不限制 |
|
||||||
|
| `port_mapping_count` | 开通时分配的 NAT 端口数量,最小 2 |
|
||||||
|
| `snapshot_limit` | 快照配额 |
|
||||||
|
| `extra_ports` | 额外映射的容器端口,逗号分隔,例如 `80,443` |
|
||||||
|
| `assign_ipv6` | 开通时是否自动分配 IPv6 |
|
||||||
|
| `sync_expiry` | 是否同步魔方到期时间到 CLICD |
|
||||||
|
|
||||||
|
客户产品的 `domain` 会作为 CLICD 容器名称。模块会自动把不适合作为容器名的字符替换为 `-`。
|
||||||
|
|
||||||
|
## 开通后字段同步
|
||||||
|
|
||||||
|
开通、同步、重装、改密后,模块会从 CLICD 容器详情拉取最新信息并写回魔方主机表:
|
||||||
|
|
||||||
|
| 魔方字段 | 写入内容 |
|
||||||
|
| --- | --- |
|
||||||
|
| `dedicatedip` | NAT 外网 IP,优先使用 API 返回的公网字段,否则使用服务器 IP |
|
||||||
|
| `username` | 固定写入 `root` |
|
||||||
|
| `password` | CLICD 返回的 SSH 密码,兼容魔方 `cmf_encrypt()` |
|
||||||
|
| `port` | CLICD 返回的 `ssh_port` |
|
||||||
|
| `domainstatus` | CLICD 状态为 `running` 时写 `Active`,否则写 `Suspended` |
|
||||||
|
|
||||||
|
如果接口返回的密码是 `***` 这类脱敏值,模块不会覆盖魔方里已有密码。
|
||||||
|
|
||||||
|
## 客户区页面
|
||||||
|
|
||||||
|
模块提供两个客户区选项卡:
|
||||||
|
|
||||||
|
```text
|
||||||
|
实例信息
|
||||||
|
NAT转发
|
||||||
|
```
|
||||||
|
|
||||||
|
客户区按钮提供:
|
||||||
|
|
||||||
|
```text
|
||||||
|
WebSSH
|
||||||
|
```
|
||||||
|
|
||||||
|
## 实例信息
|
||||||
|
|
||||||
|
实例信息页展示:
|
||||||
|
|
||||||
|
- 实例名称、运行状态、SSH 地址、IPv6
|
||||||
|
- CPU、内存、负载、磁盘圆环状态
|
||||||
|
- 月流量进度
|
||||||
|
- CPU 使用率、内存使用、网络流量、磁盘 IO 图表
|
||||||
|
- IPv4、SSH 端口、SSH 密码、资源配置、到期时间
|
||||||
|
|
||||||
|
图表数据通过客户区懒加载接口获取,不会强制刷新整个魔方页面。页面首次打开会加载一次数据,之后由用户选择是否自动刷新:
|
||||||
|
|
||||||
|
```text
|
||||||
|
不刷新
|
||||||
|
10 秒
|
||||||
|
1 分钟
|
||||||
|
5 分钟
|
||||||
|
10 分钟
|
||||||
|
```
|
||||||
|
|
||||||
|
也可以点击“立即刷新”手动刷新一次。当前 CLICD 用量接口返回的是实时值,不是历史数组;图表曲线由客户区前端持续采样生成。若需要打开页面立即显示历史曲线,需要 CLICD 额外提供历史指标接口。
|
||||||
|
|
||||||
|
流量显示支持智能单位,小流量会显示 B / KB / MB,大流量显示 GB,例如:
|
||||||
|
|
||||||
|
```text
|
||||||
|
370.5 KB / 100 GB
|
||||||
|
```
|
||||||
|
|
||||||
|
模块会优先调用:
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /api/v1/containers/{name}/usage
|
||||||
|
GET /api/v1/containers/{name}/traffic
|
||||||
|
```
|
||||||
|
|
||||||
|
如果 `/api/v1/containers/{name}/usage` 不可用,模块会在容器详情存在 `uuid` 时尝试兼容:
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /api/containers/{uuid}/usage
|
||||||
|
```
|
||||||
|
|
||||||
|
已兼容的常见用量字段包括:
|
||||||
|
|
||||||
|
```text
|
||||||
|
cpu_usage_pct
|
||||||
|
memory_usage_bytes
|
||||||
|
disk_usage_bytes
|
||||||
|
network_rx_bps
|
||||||
|
network_tx_bps
|
||||||
|
disk_read_bps
|
||||||
|
disk_write_bps
|
||||||
|
rx_used_bytes
|
||||||
|
tx_used_bytes
|
||||||
|
total_used_bytes
|
||||||
|
limit_gb
|
||||||
|
used_pct
|
||||||
|
```
|
||||||
|
|
||||||
|
## NAT 转发
|
||||||
|
|
||||||
|
NAT 转发是独立页面,支持:
|
||||||
|
|
||||||
|
- 查看端口映射
|
||||||
|
- 获取随机可用端口
|
||||||
|
- 添加端口映射
|
||||||
|
- 修改端口映射
|
||||||
|
- 删除端口映射
|
||||||
|
|
||||||
|
删除端口映射时使用页面内确认弹窗,不使用浏览器自带确认框。
|
||||||
|
|
||||||
|
使用的 CLICD API:
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /api/v1/containers/{id|uuid|name}
|
||||||
|
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}
|
||||||
|
```
|
||||||
|
|
||||||
|
添加 / 修改 NAT 映射时必须使用 JSON 请求体,例如:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"container_port": 8080,
|
||||||
|
"host_port": 61320,
|
||||||
|
"protocol": "tcp",
|
||||||
|
"description": "HTTP"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## WebSSH
|
||||||
|
|
||||||
|
WebSSH 按钮会调用:
|
||||||
|
|
||||||
|
```text
|
||||||
|
POST /api/v1/ssh-ticket
|
||||||
|
```
|
||||||
|
|
||||||
|
请求体:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"container_name": "example-vm"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
接口返回 60 秒有效票据后,模块会打开本地 handler:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/plugins/servers/clicd/handlers/webssh.php
|
||||||
|
```
|
||||||
|
|
||||||
|
浏览器会从该页面直连 CLICD:
|
||||||
|
|
||||||
|
```text
|
||||||
|
wss://0.0.0.0:8999/api/ssh?container=example-vm
|
||||||
|
Sec-WebSocket-Protocol: clicd-ticket.xxxxx
|
||||||
|
```
|
||||||
|
|
||||||
|
注意:WebSSH 受浏览器安全策略和 CLICD 后端 Origin 校验影响。魔方客户区通常是 HTTPS,因此 CLICD 面板也必须启用 HTTPS/WSS。请把魔方服务器配置里的 `主机名` 改为 `https://0.0.0.0:8999`,或把 `secure` 设为 `开启`。
|
||||||
|
|
||||||
|
新版 CLICD 已支持 WebSSH Origin 放行。部署时需要在 CLICD 后端把魔方财务客户区域名加入 WebSSH Origin 白名单,例如:
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://www.example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
如果 WebSSH 页面显示 `WebSocket error`、`Disconnected code=1006`,但直接以 CLICD 自身 Origin 测试能返回 `101 Switching Protocols`,通常说明 CLICD 后端未放行魔方客户区域名的 WebSocket Origin。此时请检查 CLICD 的 WebSSH Origin 白名单配置;前端页面无法伪造浏览器 Origin。
|
||||||
|
|
||||||
|
## 支持的魔方操作
|
||||||
|
|
||||||
|
| 魔方操作 | CLICD API |
|
||||||
|
| --- | --- |
|
||||||
|
| 连接测试 | `GET /api/v1/dashboard` |
|
||||||
|
| 开通 | `POST /api/v1/containers` |
|
||||||
|
| 删除 | `DELETE /api/v1/containers/{name}/delete` |
|
||||||
|
| 开机 | `POST /api/v1/containers/{name}/start` |
|
||||||
|
| 关机 | `POST /api/v1/containers/{name}/stop` |
|
||||||
|
| 重启 | `POST /api/v1/containers/{name}/restart` |
|
||||||
|
| 重装 | `POST /api/v1/containers/{name}/reinstall` |
|
||||||
|
| 改密 | `POST /api/v1/containers/{name}/reset-password` |
|
||||||
|
| 重置流量 | `POST /api/v1/containers/{name}/traffic-reset` |
|
||||||
|
| 变更资源 | `PUT /api/v1/containers/{name}/resource-limit` |
|
||||||
|
| 变更流量 | `PUT /api/v1/containers/{name}/traffic-limit` |
|
||||||
|
| 同步到期 | `PUT /api/v1/containers/{name}/expiry` |
|
||||||
|
| WebSSH | `POST /api/v1/ssh-ticket` |
|
||||||
|
|
||||||
|
## 建议 API 权限
|
||||||
|
|
||||||
|
API Key 至少需要以下权限,具体名称以 CLICD 后端实际权限系统为准:
|
||||||
|
|
||||||
|
```text
|
||||||
|
dashboard:read
|
||||||
|
container:read
|
||||||
|
container:create
|
||||||
|
container:power
|
||||||
|
container:delete
|
||||||
|
container:reinstall
|
||||||
|
container:password
|
||||||
|
container:traffic
|
||||||
|
container:resize
|
||||||
|
container:port
|
||||||
|
task:read
|
||||||
|
ssh-ticket:create
|
||||||
|
```
|
||||||
|
|
||||||
|
如果 API Key 使用 `*` 或 `admin:*`,通常可以覆盖上述权限。
|
||||||
|
|
||||||
|
## 建议先测试的 curl
|
||||||
|
|
||||||
|
连接测试:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -H "X-API-Key: clicd_sk_xxxx" \
|
||||||
|
https://0.0.0.0:8999/api/v1/dashboard
|
||||||
|
```
|
||||||
|
|
||||||
|
容器详情:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -H "X-API-Key: clicd_sk_xxxx" \
|
||||||
|
https://0.0.0.0:8999/api/v1/containers/example-vm
|
||||||
|
```
|
||||||
|
|
||||||
|
资源用量:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -H "X-API-Key: clicd_sk_xxxx" \
|
||||||
|
https://0.0.0.0:8999/api/v1/containers/example-vm/usage
|
||||||
|
```
|
||||||
|
|
||||||
|
流量统计:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -H "X-API-Key: clicd_sk_xxxx" \
|
||||||
|
https://0.0.0.0:8999/api/v1/containers/example-vm/traffic
|
||||||
|
```
|
||||||
|
|
||||||
|
修改 NAT:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl --location --request PUT \
|
||||||
|
"https://0.0.0.0:8999/api/v1/containers/10/port-mappings/1" \
|
||||||
|
--header "X-API-Key: clicd_sk_xxxx" \
|
||||||
|
--header "Authorization: Bearer clicd_sk_xxxx" \
|
||||||
|
--header "Content-Type: application/json" \
|
||||||
|
--data-raw '{"container_port":8081,"host_port":61320,"protocol":"tcp","description":"HTTP"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
创建 WebSSH 票据:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl --location --request POST \
|
||||||
|
"https://0.0.0.0:8999/api/v1/ssh-ticket" \
|
||||||
|
--header "X-API-Key: clicd_sk_xxxx" \
|
||||||
|
--header "Content-Type: application/json" \
|
||||||
|
--data-raw '{"container_name":"example-vm"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
### NAT 修改不生效
|
||||||
|
|
||||||
|
确认请求体必须是 JSON,不要使用 `multipart/form-data`。正确请求头:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
### 图表刚打开只有一条横线
|
||||||
|
|
||||||
|
CLICD 当前用量接口返回的是实时值,不是历史序列。页面刚打开时只有一个采样点,所以会显示当前值横线。选择 `10 秒` 自动刷新或点击“立即刷新”多采样几次后,会逐步形成折线。
|
||||||
|
|
||||||
|
### 流量显示为 0
|
||||||
|
|
||||||
|
旧版本只显示 GB,小流量换算后会被四舍五入成 `0 GB`。当前版本已改为智能单位,会显示 B / KB / MB / GB。
|
||||||
|
|
||||||
|
### WebSSH 打不开或提示不安全 WebSocket
|
||||||
|
|
||||||
|
请确认 CLICD 面板已经启用 HTTPS/WSS,并且魔方服务器配置使用 HTTPS:
|
||||||
|
|
||||||
|
```text
|
||||||
|
server_host = https://0.0.0.0:8999
|
||||||
|
```
|
||||||
|
|
||||||
|
如果仍然使用 `http://`,模块会生成 `ws://` 地址,HTTPS 客户区页面会被浏览器拦截。
|
||||||
|
|
||||||
|
如果 WSS 证书正常但仍返回 `Forbidden` 或浏览器显示 `code=1006`,请检查 CLICD 的 WebSSH Origin 白名单。新版 CLICD 已支持放行魔方财务域名,需要把魔方客户区访问域名完整加入白名单,例如:
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://www.example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
注意需要填写浏览器实际访问魔方客户区时的协议和域名,`http` / `https`、带不带 `www` 都要与实际访问地址一致。
|
||||||
|
|
||||||
|
### 开通后魔方里的 IP、端口、密码不对
|
||||||
|
|
||||||
|
执行“同步状态”或重装 / 改密后,模块会重新拉取容器详情。请确认 CLICD 容器详情接口能返回:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ssh_port
|
||||||
|
ssh_password
|
||||||
|
status
|
||||||
|
```
|
||||||
|
|
||||||
|
公网 IP 优先使用 `nat_public_ip/public_ip/host_ip/external_ip/node_ip/nat_host` 等字段;如果接口没有返回,则使用魔方服务器配置的 IP。
|
||||||
+1368
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,384 @@
|
|||||||
|
<?php
|
||||||
|
$ws = isset($_GET['ws']) ? (string)$_GET['ws'] : (isset($_GET['amp;ws']) ? (string)$_GET['amp;ws'] : '');
|
||||||
|
$protocol = isset($_GET['protocol']) ? (string)$_GET['protocol'] : (isset($_GET['amp;protocol']) ? (string)$_GET['amp;protocol'] : '');
|
||||||
|
$container = isset($_GET['container']) ? (string)$_GET['container'] : (isset($_GET['amp;container']) ? (string)$_GET['amp;container'] : '');
|
||||||
|
|
||||||
|
if ($ws === '' || $protocol === '') {
|
||||||
|
http_response_code(400);
|
||||||
|
header('Content-Type: text/plain; charset=utf-8');
|
||||||
|
echo "Missing WebSSH parameters\n";
|
||||||
|
echo "Received query: " . ($_SERVER['QUERY_STRING'] ?? '') . "\n";
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>WebSSH</title>
|
||||||
|
<style>
|
||||||
|
html,body{height:100%;margin:0;background:#0b1020;color:#e5e7eb;font-family:Consolas,Menlo,monospace}
|
||||||
|
body{cursor:text}
|
||||||
|
.bar{height:44px;display:flex;align-items:center;gap:12px;padding:0 14px;background:#111827;border-bottom:1px solid #243047}
|
||||||
|
.dot{width:9px;height:9px;border-radius:50%;background:#f59e0b}
|
||||||
|
.dot.ok{background:#22c55e}.dot.err{background:#ef4444}
|
||||||
|
.title{font-size:14px;color:#cbd5e1;flex:1}
|
||||||
|
.tools{display:flex;align-items:center;gap:8px;font-size:12px;color:#94a3b8;flex-wrap:wrap;justify-content:flex-end}
|
||||||
|
.tools select{height:26px;background:#0f172a;color:#cbd5e1;border:1px solid #334155;border-radius:4px}
|
||||||
|
.tools button{height:26px;border:1px solid #334155;background:#0f172a;color:#cbd5e1;border-radius:4px;padding:0 8px;cursor:pointer}
|
||||||
|
#keyhint{min-width:44px;text-align:right}
|
||||||
|
#iostat{min-width:120px;text-align:right}
|
||||||
|
#term{height:calc(100% - 89px);box-sizing:border-box;padding:14px;overflow:auto;white-space:pre-wrap;word-break:break-word;font-size:14px;line-height:1.45;outline:none}
|
||||||
|
.inputbar{height:44px;display:flex;align-items:center;gap:8px;padding:6px 10px;box-sizing:border-box;background:#111827;border-top:1px solid #243047}
|
||||||
|
#cmd{flex:1;height:30px;background:#020617;color:#e5e7eb;border:1px solid #334155;border-radius:4px;padding:0 8px;font:14px Consolas,Menlo,monospace;outline:none}
|
||||||
|
#sendcmd{height:30px;border:1px solid #2563eb;background:#2563eb;color:#fff;border-radius:4px;padding:0 12px;cursor:pointer}
|
||||||
|
.hint{color:#94a3b8}
|
||||||
|
.meta{color:#94a3b8}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="bar">
|
||||||
|
<span id="state" class="dot"></span>
|
||||||
|
<span class="title">WebSSH <?php echo htmlspecialchars($container, ENT_QUOTES, 'UTF-8'); ?></span>
|
||||||
|
<span class="tools">
|
||||||
|
<span>发送模式</span>
|
||||||
|
<select id="send-mode">
|
||||||
|
<option value="raw" selected>raw</option>
|
||||||
|
<option value="binary">binary</option>
|
||||||
|
<option value="json-input">json input</option>
|
||||||
|
<option value="json-data">json data</option>
|
||||||
|
<option value="json-stdin">json stdin</option>
|
||||||
|
</select>
|
||||||
|
<button id="send-enter" type="button">回车</button>
|
||||||
|
<span id="iostat">S0 R0</span>
|
||||||
|
<span id="keyhint"></span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div id="term" tabindex="0"><span class="hint">正在连接...</span></div>
|
||||||
|
<div class="inputbar">
|
||||||
|
<input id="cmd" type="text" autocomplete="off" spellcheck="false" placeholder="在这里输入命令,例如 ls -la">
|
||||||
|
<button id="sendcmd" type="button">发送</button>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
var wsUrl = <?php echo json_encode($ws, JSON_UNESCAPED_SLASHES); ?>;
|
||||||
|
var protocol = <?php echo json_encode($protocol, JSON_UNESCAPED_SLASHES); ?>;
|
||||||
|
var term = document.getElementById('term');
|
||||||
|
var state = document.getElementById('state');
|
||||||
|
var modeSelect = document.getElementById('send-mode');
|
||||||
|
var keyhint = document.getElementById('keyhint');
|
||||||
|
var iostat = document.getElementById('iostat');
|
||||||
|
var sendEnter = document.getElementById('send-enter');
|
||||||
|
var cmd = document.getElementById('cmd');
|
||||||
|
var sendcmd = document.getElementById('sendcmd');
|
||||||
|
var socket;
|
||||||
|
var hintTimer;
|
||||||
|
var sentCount = 0;
|
||||||
|
var recvCount = 0;
|
||||||
|
var decoder = window.TextDecoder ? new TextDecoder('utf-8') : null;
|
||||||
|
var termLines = [''];
|
||||||
|
var cursorRow = 0;
|
||||||
|
var cursorCol = 0;
|
||||||
|
var maxLines = 2000;
|
||||||
|
|
||||||
|
function append(text) {
|
||||||
|
writeTerminal(stripTerminalControls(String(text || '')));
|
||||||
|
renderTerminal();
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearTerminal() {
|
||||||
|
termLines = [''];
|
||||||
|
cursorRow = 0;
|
||||||
|
cursorCol = 0;
|
||||||
|
renderTerminal();
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripTerminalControls(text) {
|
||||||
|
return text
|
||||||
|
.replace(/\x1b\][\s\S]*?(?:\x07|\x1b\\)/g, '')
|
||||||
|
.replace(/\x1b\[(?:2J|H)/g, '\f')
|
||||||
|
.replace(/\x1b\[[0-?]*[ -/]*K/g, '\v')
|
||||||
|
.replace(/\ufffd\[[0-?]*[ -/]*K/g, '\v')
|
||||||
|
.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '')
|
||||||
|
.replace(/\ufffd\[[0-?]*[ -/]*[@-~]/g, '')
|
||||||
|
.replace(/\x1b[()][A-Za-z0-9]/g, '')
|
||||||
|
.replace(/\x1b[@-Z\\-_]/g, '')
|
||||||
|
.replace(/[\x00-\x08\x0e-\x1f\x7f]/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureLine() {
|
||||||
|
while (cursorRow >= termLines.length) {
|
||||||
|
termLines.push('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function trimTerminal() {
|
||||||
|
if (termLines.length <= maxLines) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var overflow = termLines.length - maxLines;
|
||||||
|
termLines.splice(0, overflow);
|
||||||
|
cursorRow = Math.max(0, cursorRow - overflow);
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeTerminal(text) {
|
||||||
|
for (var i = 0; i < text.length; i++) {
|
||||||
|
var ch = text.charAt(i);
|
||||||
|
if (ch === '\f') {
|
||||||
|
termLines = [''];
|
||||||
|
cursorRow = 0;
|
||||||
|
cursorCol = 0;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === '\v') {
|
||||||
|
ensureLine();
|
||||||
|
termLines[cursorRow] = termLines[cursorRow].slice(0, cursorCol);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === '\r') {
|
||||||
|
cursorCol = 0;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === '\n') {
|
||||||
|
cursorRow++;
|
||||||
|
cursorCol = 0;
|
||||||
|
ensureLine();
|
||||||
|
trimTerminal();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === '\b') {
|
||||||
|
cursorCol = Math.max(0, cursorCol - 1);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === '\t') {
|
||||||
|
var spaces = 4 - (cursorCol % 4);
|
||||||
|
for (var s = 0; s < spaces; s++) {
|
||||||
|
writePrintable(' ');
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
writePrintable(ch);
|
||||||
|
}
|
||||||
|
trimTerminal();
|
||||||
|
}
|
||||||
|
|
||||||
|
function writePrintable(ch) {
|
||||||
|
ensureLine();
|
||||||
|
var line = termLines[cursorRow];
|
||||||
|
if (cursorCol > line.length) {
|
||||||
|
line += new Array(cursorCol - line.length + 1).join(' ');
|
||||||
|
}
|
||||||
|
termLines[cursorRow] = line.slice(0, cursorCol) + ch + line.slice(cursorCol + 1);
|
||||||
|
cursorCol++;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTerminal() {
|
||||||
|
term.textContent = termLines.join('\n');
|
||||||
|
term.scrollTop = term.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setState(cls, text) {
|
||||||
|
state.className = 'dot ' + cls;
|
||||||
|
append(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateIoStatus() {
|
||||||
|
if (!iostat) return;
|
||||||
|
var stateText = socket ? ['CONNECTING','OPEN','CLOSING','CLOSED'][socket.readyState] : '-';
|
||||||
|
iostat.textContent = 'S' + sentCount + ' R' + recvCount + ' ' + stateText;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
socket = new WebSocket(wsUrl, protocol);
|
||||||
|
socket.binaryType = 'arraybuffer';
|
||||||
|
} catch (e) {
|
||||||
|
setState('err', '\nWebSocket 创建失败:' + e.message + '\n');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.onopen = function(){
|
||||||
|
clearTerminal();
|
||||||
|
setState('ok', '已连接。\r\n');
|
||||||
|
updateIoStatus();
|
||||||
|
if (cmd) cmd.focus();
|
||||||
|
};
|
||||||
|
socket.onmessage = function(event){
|
||||||
|
recvCount++;
|
||||||
|
updateIoStatus();
|
||||||
|
if (typeof event.data === 'string') {
|
||||||
|
handleIncomingText(event.data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.data instanceof ArrayBuffer) {
|
||||||
|
handleIncomingText(decodeIncoming(event.data));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (window.Blob && event.data instanceof Blob) {
|
||||||
|
event.data.arrayBuffer().then(function(buffer){
|
||||||
|
handleIncomingText(decodeIncoming(buffer));
|
||||||
|
}).catch(function(){
|
||||||
|
append('\n[WebSSH] 无法解码服务端返回内容。\n');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
socket.onerror = function(){
|
||||||
|
setState('err', '\nWebSocket 连接错误,请检查 HTTPS 证书、WSS 服务、Origin 策略和票据有效期。\n');
|
||||||
|
};
|
||||||
|
socket.onclose = function(event){
|
||||||
|
updateIoStatus();
|
||||||
|
setState('err', '\n连接已断开。code=' + event.code + ' reason=' + (event.reason || '-') + ' clean=' + event.wasClean + '\n');
|
||||||
|
};
|
||||||
|
|
||||||
|
function flashKey(text) {
|
||||||
|
if (!keyhint) return;
|
||||||
|
keyhint.textContent = text;
|
||||||
|
window.clearTimeout(hintTimer);
|
||||||
|
hintTimer = window.setTimeout(function(){ keyhint.textContent = ''; }, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeIncoming(buffer) {
|
||||||
|
if (decoder) {
|
||||||
|
return decoder.decode(new Uint8Array(buffer));
|
||||||
|
}
|
||||||
|
var bytes = new Uint8Array(buffer);
|
||||||
|
var text = '';
|
||||||
|
for (var i = 0; i < bytes.length; i++) {
|
||||||
|
text += String.fromCharCode(bytes[i]);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(escape(text));
|
||||||
|
} catch (e) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleIncomingText(text) {
|
||||||
|
append(text);
|
||||||
|
if (text.indexOf('SSH shell ready') !== -1) {
|
||||||
|
window.setTimeout(function(){ send('\r'); }, 250);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function wsPayload(data) {
|
||||||
|
var mode = modeSelect ? modeSelect.value : 'raw';
|
||||||
|
if (mode === 'raw') {
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
if (mode === 'binary') {
|
||||||
|
return new TextEncoder().encode(data);
|
||||||
|
}
|
||||||
|
if (mode === 'json-data') {
|
||||||
|
return JSON.stringify({type:'data', data:data});
|
||||||
|
}
|
||||||
|
if (mode === 'json-stdin') {
|
||||||
|
return JSON.stringify({type:'stdin', data:data});
|
||||||
|
}
|
||||||
|
return JSON.stringify({type:'input', data:data});
|
||||||
|
}
|
||||||
|
|
||||||
|
function send(data) {
|
||||||
|
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
socket.send(wsPayload(data));
|
||||||
|
sentCount++;
|
||||||
|
updateIoStatus();
|
||||||
|
flashKey(data === '\r' ? '回车' : data === '\x7f' ? '退格' : data.length > 1 ? data.length + ' 字符' : data);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function keyToData(e) {
|
||||||
|
if (e.ctrlKey && !e.altKey && !e.metaKey && e.key.length === 1) {
|
||||||
|
var code = e.key.toUpperCase().charCodeAt(0);
|
||||||
|
if (code >= 64 && code <= 95) {
|
||||||
|
return String.fromCharCode(code - 64);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var map = {
|
||||||
|
Enter: '\r',
|
||||||
|
Backspace: '\x7f',
|
||||||
|
Tab: '\t',
|
||||||
|
Escape: '\x1b',
|
||||||
|
ArrowUp: '\x1b[A',
|
||||||
|
ArrowDown: '\x1b[B',
|
||||||
|
ArrowRight: '\x1b[C',
|
||||||
|
ArrowLeft: '\x1b[D',
|
||||||
|
Delete: '\x1b[3~',
|
||||||
|
Home: '\x1b[H',
|
||||||
|
End: '\x1b[F',
|
||||||
|
PageUp: '\x1b[5~',
|
||||||
|
PageDown: '\x1b[6~'
|
||||||
|
};
|
||||||
|
if (map[e.key]) {
|
||||||
|
return map[e.key];
|
||||||
|
}
|
||||||
|
if (!e.ctrlKey && !e.altKey && !e.metaKey && e.key.length === 1) {
|
||||||
|
return e.key;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('keydown', function(e){
|
||||||
|
if (e.target === cmd) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var data = keyToData(e);
|
||||||
|
if (data !== null && send(data)) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('paste', function(e){
|
||||||
|
if (e.target === cmd) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var text = e.clipboardData ? e.clipboardData.getData('text/plain') : '';
|
||||||
|
if (text && send(text)) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', function(){
|
||||||
|
if (cmd) cmd.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
function sendCommandLine() {
|
||||||
|
if (!cmd) return;
|
||||||
|
var value = cmd.value;
|
||||||
|
if (value === '') {
|
||||||
|
send('\r');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (send(value + '\r')) {
|
||||||
|
cmd.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sendcmd) {
|
||||||
|
sendcmd.addEventListener('click', sendCommandLine);
|
||||||
|
}
|
||||||
|
if (sendEnter) {
|
||||||
|
sendEnter.addEventListener('click', function(){
|
||||||
|
send('\r');
|
||||||
|
if (cmd) cmd.focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (cmd) {
|
||||||
|
cmd.addEventListener('keydown', function(e){
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
sendCommandLine();
|
||||||
|
e.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.ctrlKey && e.key.toLowerCase() === 'c') {
|
||||||
|
send('\x03');
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
window.setInterval(updateIoStatus, 1000);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,378 @@
|
|||||||
|
<style>
|
||||||
|
.clicd-info{font-size:14px;color:#1f2937;background:#f6f8fb;padding:14px;border-radius:6px;max-width:100%;overflow:hidden}
|
||||||
|
.clicd-info *{box-sizing:border-box}
|
||||||
|
.clicd-head{display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:10px;margin-bottom:12px}
|
||||||
|
.clicd-mini{background:#fff;border:1px solid #e5e7eb;border-radius:6px;padding:10px}
|
||||||
|
.clicd-mini-label{font-size:12px;color:#6b7280;margin-bottom:4px}
|
||||||
|
.clicd-mini-value{font-size:16px;font-weight:600;color:#111827;word-break:break-all}
|
||||||
|
.clicd-section{background:#fff;border:1px solid #e5e7eb;border-radius:6px;margin-top:12px;padding:14px}
|
||||||
|
.clicd-section-title{display:flex;align-items:center;justify-content:space-between;gap:10px;font-size:15px;font-weight:700;margin-bottom:12px;color:#111827;min-width:0;flex-wrap:wrap}
|
||||||
|
.clicd-section-title>span:first-child{min-width:0}
|
||||||
|
.clicd-refresh{display:flex;align-items:center;justify-content:flex-end;gap:8px;font-size:12px;color:#6b7280;font-weight:400;flex-wrap:wrap;min-width:0;max-width:100%}
|
||||||
|
.clicd-refresh label{display:inline-flex;align-items:center;gap:4px;min-width:0;white-space:nowrap}
|
||||||
|
.clicd-refresh-select{height:28px;border:1px solid #d1d5db;border-radius:4px;background:#fff;color:#374151;padding:3px 6px;font-size:12px}
|
||||||
|
.clicd-refresh-btn{height:28px;border:1px solid #2563eb;background:#2563eb;color:#fff;border-radius:4px;padding:3px 8px;font-size:12px;cursor:pointer;white-space:nowrap;max-width:96px;overflow:hidden;text-overflow:ellipsis}
|
||||||
|
.clicd-refresh-btn[disabled]{opacity:.6;cursor:not-allowed}
|
||||||
|
.clicd-gauges{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:12px}
|
||||||
|
.clicd-gauge{display:flex;align-items:center;gap:12px;min-height:92px}
|
||||||
|
.clicd-ring{--p:0%;width:78px;height:78px;border-radius:50%;background:conic-gradient(#2f80ed var(--p),#e5e7eb 0);display:grid;place-items:center;flex:0 0 auto;position:relative}
|
||||||
|
.clicd-ring:before{content:"";width:66px;height:66px;border-radius:50%;background:#fff;position:absolute}
|
||||||
|
.clicd-ring span{position:relative;display:inline-flex;align-items:center;justify-content:center;max-width:62px;min-width:0;font-size:17px;font-weight:700;line-height:1;color:#111827;white-space:nowrap;text-align:center;background:#fff;border-radius:3px;padding:0 1px}
|
||||||
|
.clicd-ring[data-tight="1"] span{font-size:15px}
|
||||||
|
.clicd-ring[data-tight="2"] span{font-size:14px}
|
||||||
|
.clicd-gauge-title{font-weight:700;color:#111827;margin-bottom:4px}
|
||||||
|
.clicd-gauge-sub{font-size:12px;color:#6b7280;line-height:1.45}
|
||||||
|
.clicd-progress{height:12px;background:#e5e7eb;border-radius:999px;overflow:hidden}
|
||||||
|
.clicd-progress span{display:block;height:100%;width:0;background:linear-gradient(90deg,#2f80ed,#10b981);transition:width .25s ease}
|
||||||
|
.clicd-traffic-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:10px;align-items:center;margin-top:8px;color:#374151;min-width:0}
|
||||||
|
.clicd-traffic-row>div{min-width:0;word-break:break-word}
|
||||||
|
.clicd-charts{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:12px}
|
||||||
|
.clicd-chart{border:1px solid #e5e7eb;border-radius:6px;padding:12px;background:#fff;min-height:190px;min-width:0;overflow:hidden}
|
||||||
|
.clicd-chart-title{display:flex;justify-content:space-between;gap:8px;align-items:center;font-weight:700;margin-bottom:8px;color:#111827;min-width:0;flex-wrap:wrap}
|
||||||
|
.clicd-chart-value{font-size:12px;color:#6b7280;font-weight:400;white-space:normal;overflow-wrap:anywhere;text-align:right}
|
||||||
|
.clicd-chart canvas{width:100%;height:132px;display:block}
|
||||||
|
.clicd-table{width:100%;border-collapse:collapse;background:#fff}
|
||||||
|
.clicd-table th,.clicd-table td{border:1px solid #e5e7eb;padding:8px;text-align:left}
|
||||||
|
.clicd-table th{width:16%;background:#f9fafb;color:#374151;font-weight:600}
|
||||||
|
.clicd-debug{display:none;margin-top:10px;padding:8px;background:#fff7ed;border:1px solid #fed7aa;color:#9a3412;border-radius:6px;font-size:12px}
|
||||||
|
@media (max-width:640px){
|
||||||
|
.clicd-info{padding:10px}
|
||||||
|
.clicd-section-title{align-items:flex-start}
|
||||||
|
.clicd-refresh{justify-content:flex-start;width:100%}
|
||||||
|
.clicd-charts{grid-template-columns:1fr}
|
||||||
|
.clicd-table th,.clicd-table td{display:block;width:100%}
|
||||||
|
.clicd-ring{width:70px;height:70px}
|
||||||
|
.clicd-ring:before{width:60px;height:60px}
|
||||||
|
.clicd-ring span{max-width:56px;font-size:15px}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div class="clicd-info" data-clicd-info-root="1">
|
||||||
|
<div class="clicd-head">
|
||||||
|
<div class="clicd-mini">
|
||||||
|
<div class="clicd-mini-label">实例名称</div>
|
||||||
|
<div class="clicd-mini-value">{$container.name|default='-'}</div>
|
||||||
|
</div>
|
||||||
|
<div class="clicd-mini">
|
||||||
|
<div class="clicd-mini-label">运行状态</div>
|
||||||
|
<div class="clicd-mini-value">{$status_text|default='-'}</div>
|
||||||
|
</div>
|
||||||
|
<div class="clicd-mini">
|
||||||
|
<div class="clicd-mini-label">SSH 地址</div>
|
||||||
|
<div class="clicd-mini-value">{$ssh_host|default='-'}:{$ssh_port|default='-'}</div>
|
||||||
|
</div>
|
||||||
|
<div class="clicd-mini">
|
||||||
|
<div class="clicd-mini-label">IPv6</div>
|
||||||
|
<div class="clicd-mini-value">{$ipv6|default='-'}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="clicd-section">
|
||||||
|
<div class="clicd-section-title">
|
||||||
|
<span>状态</span>
|
||||||
|
<span class="clicd-refresh">
|
||||||
|
<span>更新于 <span data-clicd-info="chart_time">-</span></span>
|
||||||
|
<label>
|
||||||
|
自动刷新
|
||||||
|
<select class="clicd-refresh-select" id="clicd-info-refresh">
|
||||||
|
<option value="0" selected>不刷新</option>
|
||||||
|
<option value="10000">10 秒</option>
|
||||||
|
<option value="60000">1 分钟</option>
|
||||||
|
<option value="300000">5 分钟</option>
|
||||||
|
<option value="600000">10 分钟</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button class="clicd-refresh-btn" type="button" id="clicd-info-refresh-now">立即刷新</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="clicd-gauges">
|
||||||
|
<div class="clicd-gauge">
|
||||||
|
<div class="clicd-ring" data-gauge="cpu_percent"><span><span data-clicd-info="cpu_percent">0</span>%</span></div>
|
||||||
|
<div>
|
||||||
|
<div class="clicd-gauge-title">CPU</div>
|
||||||
|
<div class="clicd-gauge-sub" data-clicd-info="cpu_detail">-</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="clicd-gauge">
|
||||||
|
<div class="clicd-ring" data-gauge="mem_percent"><span><span data-clicd-info="mem_percent">0</span>%</span></div>
|
||||||
|
<div>
|
||||||
|
<div class="clicd-gauge-title">内存</div>
|
||||||
|
<div class="clicd-gauge-sub" data-clicd-info="mem_detail">-</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="clicd-gauge">
|
||||||
|
<div class="clicd-ring" data-gauge="load_percent"><span><span data-clicd-info="load_percent">0</span>%</span></div>
|
||||||
|
<div>
|
||||||
|
<div class="clicd-gauge-title">负载</div>
|
||||||
|
<div class="clicd-gauge-sub" data-clicd-info="load_detail">-</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="clicd-gauge">
|
||||||
|
<div class="clicd-ring" data-gauge="disk_percent"><span><span data-clicd-info="disk_percent">0</span>%</span></div>
|
||||||
|
<div>
|
||||||
|
<div class="clicd-gauge-title">磁盘</div>
|
||||||
|
<div class="clicd-gauge-sub" data-clicd-info="disk_detail">-</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top:14px">
|
||||||
|
<div class="clicd-traffic-row">
|
||||||
|
<div>月流量</div>
|
||||||
|
<div><span data-clicd-info="traffic_used_text">{$traffic_used_text|default='-'}</span> / <span data-clicd-info="traffic_limit_text">{$traffic_limit_text|default='-'}</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="clicd-progress"><span data-progress="traffic_percent"></span></div>
|
||||||
|
<div class="clicd-traffic-row" style="font-size:12px;color:#6b7280">
|
||||||
|
<div>入站 <span data-clicd-info="traffic_in_text">{$traffic_in_text|default='-'}</span></div>
|
||||||
|
<div>出站 <span data-clicd-info="traffic_out_text">{$traffic_out_text|default='-'}</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="clicd-section">
|
||||||
|
<div class="clicd-section-title"><span>统计信息</span></div>
|
||||||
|
<div class="clicd-charts">
|
||||||
|
<div class="clicd-chart">
|
||||||
|
<div class="clicd-chart-title">CPU 使用率 <span class="clicd-chart-value" data-clicd-info="cpu_detail">-</span></div>
|
||||||
|
<canvas data-chart="cpu_percent"></canvas>
|
||||||
|
</div>
|
||||||
|
<div class="clicd-chart">
|
||||||
|
<div class="clicd-chart-title">内存使用 <span class="clicd-chart-value" data-clicd-info="mem_detail">-</span></div>
|
||||||
|
<canvas data-chart="mem_percent"></canvas>
|
||||||
|
</div>
|
||||||
|
<div class="clicd-chart">
|
||||||
|
<div class="clicd-chart-title">网络流量 <span class="clicd-chart-value"><span data-clicd-info="net_in_rate">0 B/s</span> / <span data-clicd-info="net_out_rate">0 B/s</span></span></div>
|
||||||
|
<canvas data-chart="network"></canvas>
|
||||||
|
</div>
|
||||||
|
<div class="clicd-chart">
|
||||||
|
<div class="clicd-chart-title">磁盘 IO <span class="clicd-chart-value"><span data-clicd-info="disk_read_rate">0 B/s</span> / <span data-clicd-info="disk_write_rate">0 B/s</span></span></div>
|
||||||
|
<canvas data-chart="diskio"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="clicd-section">
|
||||||
|
<div class="clicd-section-title"><span>实例信息</span></div>
|
||||||
|
<table class="clicd-table">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<th>IPv4</th><td>{$ipv4|default='-'}</td>
|
||||||
|
<th>用户名</th><td>root</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>SSH 端口</th><td>{$ssh_port|default='-'}</td>
|
||||||
|
<th>SSH 密码</th><td>{$ssh_password|default='-'}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>CPU</th><td>{$vcpu|default='-'} 核</td>
|
||||||
|
<th>内存</th><td>{$ram_mb|default='-'} MB</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>硬盘</th><td>{$disk_gb|default='-'} GB</td>
|
||||||
|
<th>带宽</th><td>{$bandwidth|default='-'} Mbps</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>到期时间</th><td colspan="3">{$expires_at|default='-'}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="clicd-debug" id="clicd-info-debug"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
var root = document.querySelector('[data-clicd-info-root="1"]:not([data-info-bound="1"])');
|
||||||
|
if (!root) return;
|
||||||
|
root.setAttribute('data-info-bound', '1');
|
||||||
|
|
||||||
|
var history = {
|
||||||
|
cpu_percent: [],
|
||||||
|
mem_percent: [],
|
||||||
|
network_in: [],
|
||||||
|
network_out: [],
|
||||||
|
disk_read: [],
|
||||||
|
disk_write: []
|
||||||
|
};
|
||||||
|
var maxPoints = 18;
|
||||||
|
var refreshTimer = null;
|
||||||
|
|
||||||
|
function endpoint() {
|
||||||
|
return "{$MODULE_CUSTOM_API}";
|
||||||
|
}
|
||||||
|
|
||||||
|
function number(value) {
|
||||||
|
var n = parseFloat(value);
|
||||||
|
return isFinite(n) ? n : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function push(name, value) {
|
||||||
|
history[name].push(number(value));
|
||||||
|
if (history[name].length > maxPoints) history[name].shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setText(key, value) {
|
||||||
|
root.querySelectorAll('[data-clicd-info="' + key + '"]').forEach(function(node){
|
||||||
|
if (typeof value !== 'object') node.textContent = value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setGauge(key, value) {
|
||||||
|
var pct = Math.max(0, Math.min(100, number(value)));
|
||||||
|
root.querySelectorAll('[data-gauge="' + key + '"]').forEach(function(node){
|
||||||
|
node.style.setProperty('--p', pct + '%');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function fitGaugeText() {
|
||||||
|
root.querySelectorAll('.clicd-ring').forEach(function(ring){
|
||||||
|
var label = ring.querySelector('span');
|
||||||
|
if (!label) return;
|
||||||
|
ring.removeAttribute('data-tight');
|
||||||
|
if (label.scrollWidth > label.clientWidth) {
|
||||||
|
ring.setAttribute('data-tight', '1');
|
||||||
|
}
|
||||||
|
if (label.scrollWidth > label.clientWidth) {
|
||||||
|
ring.setAttribute('data-tight', '2');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setProgress(key, value) {
|
||||||
|
var pct = Math.max(0, Math.min(100, number(value)));
|
||||||
|
root.querySelectorAll('[data-progress="' + key + '"]').forEach(function(node){
|
||||||
|
node.style.width = pct + '%';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function draw(canvas, series, colors, maxValue) {
|
||||||
|
if (!canvas || !canvas.getContext) return;
|
||||||
|
var rect = canvas.getBoundingClientRect();
|
||||||
|
var ratio = window.devicePixelRatio || 1;
|
||||||
|
var width = Math.max(220, Math.floor(rect.width || canvas.clientWidth || 220));
|
||||||
|
var height = Math.max(120, Math.floor(rect.height || canvas.clientHeight || 132));
|
||||||
|
if (canvas.width !== width * ratio || canvas.height !== height * ratio) {
|
||||||
|
canvas.width = width * ratio;
|
||||||
|
canvas.height = height * ratio;
|
||||||
|
}
|
||||||
|
var ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
ctx.setTransform(ratio, 0, 0, ratio, 0, 0);
|
||||||
|
ctx.clearRect(0, 0, width, height);
|
||||||
|
ctx.strokeStyle = '#e5e7eb';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
for (var i = 1; i < 4; i++) {
|
||||||
|
var y = Math.round((height / 4) * i);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(0, y);
|
||||||
|
ctx.lineTo(width, y);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
series.forEach(function(values, idx){
|
||||||
|
if (!values.length) return;
|
||||||
|
var color = colors[idx] || '#2f80ed';
|
||||||
|
ctx.strokeStyle = color;
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.beginPath();
|
||||||
|
if (values.length === 1) {
|
||||||
|
var singleY = height - (Math.max(0, Math.min(maxValue, number(values[0]))) / maxValue) * (height - 6) - 3;
|
||||||
|
ctx.moveTo(0, singleY);
|
||||||
|
ctx.lineTo(width, singleY);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(width - 8, singleY, 3, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
values.forEach(function(value, i){
|
||||||
|
var x = values.length <= 1 ? width : (i / (values.length - 1)) * width;
|
||||||
|
var y = height - (Math.max(0, Math.min(maxValue, number(value))) / maxValue) * (height - 6) - 3;
|
||||||
|
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
|
||||||
|
});
|
||||||
|
ctx.stroke();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function redraw() {
|
||||||
|
draw(root.querySelector('[data-chart="cpu_percent"]'), [history.cpu_percent], ['#2f80ed'], 100);
|
||||||
|
draw(root.querySelector('[data-chart="mem_percent"]'), [history.mem_percent], ['#10b981'], 100);
|
||||||
|
var netMax = Math.max(1, Math.max.apply(null, history.network_in.concat(history.network_out, [1])));
|
||||||
|
draw(root.querySelector('[data-chart="network"]'), [history.network_in, history.network_out], ['#2f80ed', '#f59e0b'], netMax);
|
||||||
|
var ioMax = Math.max(1, Math.max.apply(null, history.disk_read.concat(history.disk_write, [1])));
|
||||||
|
draw(root.querySelector('[data-chart="diskio"]'), [history.disk_read, history.disk_write], ['#10b981', '#ef4444'], ioMax);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showInfoError(text) {
|
||||||
|
var debug = document.getElementById('clicd-info-debug');
|
||||||
|
if (debug) {
|
||||||
|
debug.style.display = 'block';
|
||||||
|
debug.textContent = text || 'info load failed';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadInfo() {
|
||||||
|
var refreshNow = document.getElementById('clicd-info-refresh-now');
|
||||||
|
if (refreshNow) refreshNow.disabled = true;
|
||||||
|
var body = new URLSearchParams();
|
||||||
|
body.set('id', '{$service_id}');
|
||||||
|
body.set('func', 'infoData');
|
||||||
|
fetch(endpoint(), {
|
||||||
|
method:'POST',
|
||||||
|
headers:{
|
||||||
|
'Content-Type':'application/x-www-form-urlencoded; charset=UTF-8',
|
||||||
|
'Authorization':'JWT {$Think.get.jwt}'
|
||||||
|
},
|
||||||
|
credentials:'same-origin',
|
||||||
|
body: body.toString()
|
||||||
|
})
|
||||||
|
.then(function(res){ return res.json(); })
|
||||||
|
.then(function(json){
|
||||||
|
if (!json || (json.status !== 200 && json.status !== 'success') || !json.data) {
|
||||||
|
showInfoError(json && json.msg ? json.msg : 'info load failed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var data = json.data;
|
||||||
|
Object.keys(data).forEach(function(key){ setText(key, data[key]); });
|
||||||
|
['cpu_percent','mem_percent','load_percent','disk_percent'].forEach(function(key){ setGauge(key, data[key]); });
|
||||||
|
fitGaugeText();
|
||||||
|
setProgress('traffic_percent', data.traffic_percent);
|
||||||
|
push('cpu_percent', data.cpu_percent);
|
||||||
|
push('mem_percent', data.mem_percent);
|
||||||
|
push('network_in', data.net_in_bps);
|
||||||
|
push('network_out', data.net_out_bps);
|
||||||
|
push('disk_read', data.disk_read_bps);
|
||||||
|
push('disk_write', data.disk_write_bps);
|
||||||
|
redraw();
|
||||||
|
})
|
||||||
|
.catch(function(error){
|
||||||
|
showInfoError(error && error.message ? error.message : 'info request failed');
|
||||||
|
})
|
||||||
|
.finally(function(){
|
||||||
|
if (refreshNow) refreshNow.disabled = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
loadInfo();
|
||||||
|
var refreshSelect = document.getElementById('clicd-info-refresh');
|
||||||
|
var refreshNow = document.getElementById('clicd-info-refresh-now');
|
||||||
|
if (refreshNow) {
|
||||||
|
refreshNow.addEventListener('click', loadInfo);
|
||||||
|
}
|
||||||
|
if (refreshSelect) {
|
||||||
|
refreshSelect.addEventListener('change', function(){
|
||||||
|
if (refreshTimer) {
|
||||||
|
window.clearInterval(refreshTimer);
|
||||||
|
refreshTimer = null;
|
||||||
|
}
|
||||||
|
var ms = number(refreshSelect.value);
|
||||||
|
if (ms > 0) {
|
||||||
|
loadInfo();
|
||||||
|
refreshTimer = window.setInterval(loadInfo, ms);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
window.addEventListener('resize', function(){ window.setTimeout(function(){ fitGaugeText(); redraw(); }, 50); });
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
<style>
|
||||||
|
.clicd-nat-panel{font-size:14px;color:#1f2937}
|
||||||
|
.clicd-nat-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px;margin-bottom:16px}
|
||||||
|
.clicd-nat-card{border:1px solid #e5e7eb;border-radius:6px;padding:12px;background:#fff}
|
||||||
|
.clicd-nat-label{color:#6b7280;font-size:12px;margin-bottom:4px}
|
||||||
|
.clicd-nat-value{font-size:18px;font-weight:600;word-break:break-all}
|
||||||
|
.clicd-nat-title{font-weight:600;margin:18px 0 8px}
|
||||||
|
.clicd-nat-muted{color:#6b7280}
|
||||||
|
.clicd-nat-form{border:1px solid #e5e7eb;border-radius:6px;background:#fff;padding:12px;margin-top:8px}
|
||||||
|
.clicd-nat-row{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:10px;align-items:end}
|
||||||
|
.clicd-nat-field label{display:block;color:#6b7280;font-size:12px;margin-bottom:4px}
|
||||||
|
.clicd-nat-input,.clicd-nat-select{width:100%;height:34px;border:1px solid #d1d5db;border-radius:4px;padding:6px 8px;box-sizing:border-box}
|
||||||
|
.clicd-nat-actions{display:flex;gap:8px;flex-wrap:wrap}
|
||||||
|
.clicd-nat-btn{height:34px;border:1px solid #2563eb;background:#2563eb;color:#fff;border-radius:4px;padding:0 12px;cursor:pointer}
|
||||||
|
.clicd-nat-btn[disabled]{opacity:.6;cursor:not-allowed}
|
||||||
|
.clicd-nat-btn-secondary{border-color:#d1d5db;background:#fff;color:#374151}
|
||||||
|
.clicd-nat-btn-danger{border-color:#dc2626;background:#dc2626;color:#fff}
|
||||||
|
.clicd-nat-list{display:flex;flex-direction:column;gap:10px;margin-top:8px}
|
||||||
|
.clicd-nat-item{border:1px solid #e5e7eb;border-radius:6px;background:#fff;padding:12px}
|
||||||
|
.clicd-nat-message{border:1px solid #bfdbfe;background:#eff6ff;color:#1d4ed8;border-radius:6px;padding:10px 12px;margin-bottom:12px;display:none}
|
||||||
|
.clicd-nat-message.error{border-color:#fecaca;background:#fef2f2;color:#b91c1c}
|
||||||
|
.clicd-nat-debug{margin-top:12px;border:1px dashed #d1d5db;border-radius:6px;background:#f9fafb;padding:10px;color:#374151;white-space:pre-wrap;font-size:12px;display:none}
|
||||||
|
.clicd-nat-modal-mask{position:fixed;inset:0;background:rgba(15,23,42,.42);display:none;align-items:center;justify-content:center;z-index:9999;padding:16px}
|
||||||
|
.clicd-nat-modal{width:min(420px,100%);background:#fff;border-radius:6px;border:1px solid #e5e7eb;box-shadow:0 18px 48px rgba(15,23,42,.22);padding:16px}
|
||||||
|
.clicd-nat-modal-title{font-size:16px;font-weight:700;color:#111827;margin-bottom:8px}
|
||||||
|
.clicd-nat-modal-body{font-size:14px;color:#4b5563;line-height:1.6;margin-bottom:14px}
|
||||||
|
.clicd-nat-modal-actions{display:flex;justify-content:flex-end;gap:8px}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div class="clicd-nat-panel" id="clicd-nat-panel" data-service-id="{$service_id}" data-area-key="{$area_key}">
|
||||||
|
<div class="clicd-nat-message" id="clicd-nat-message"></div>
|
||||||
|
|
||||||
|
<div class="clicd-nat-grid">
|
||||||
|
<div class="clicd-nat-card">
|
||||||
|
<div class="clicd-nat-label">实例名称</div>
|
||||||
|
<div class="clicd-nat-value">{$container_name}</div>
|
||||||
|
</div>
|
||||||
|
<div class="clicd-nat-card">
|
||||||
|
<div class="clicd-nat-label">公网地址</div>
|
||||||
|
<div class="clicd-nat-value">{$nat_host}</div>
|
||||||
|
</div>
|
||||||
|
<div class="clicd-nat-card">
|
||||||
|
<div class="clicd-nat-label">SSH 端口</div>
|
||||||
|
<div class="clicd-nat-value">{$ssh_port}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="clicd-nat-title">添加端口映射</div>
|
||||||
|
<div class="clicd-nat-form">
|
||||||
|
<div class="clicd-nat-row">
|
||||||
|
<div class="clicd-nat-field">
|
||||||
|
<label>公网端口</label>
|
||||||
|
<input class="clicd-nat-input" id="clicd-add-host-port" type="number" min="1" max="65535" placeholder="61320">
|
||||||
|
</div>
|
||||||
|
<div class="clicd-nat-field">
|
||||||
|
<label>容器端口</label>
|
||||||
|
<input class="clicd-nat-input" id="clicd-add-container-port" type="number" min="1" max="65535" placeholder="8080">
|
||||||
|
</div>
|
||||||
|
<div class="clicd-nat-field">
|
||||||
|
<label>协议</label>
|
||||||
|
<select class="clicd-nat-select" id="clicd-add-protocol">
|
||||||
|
<option value="tcp">TCP</option>
|
||||||
|
<option value="udp">UDP</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="clicd-nat-field">
|
||||||
|
<label>说明</label>
|
||||||
|
<input class="clicd-nat-input" id="clicd-add-description" type="text" placeholder="HTTP">
|
||||||
|
</div>
|
||||||
|
<div class="clicd-nat-actions">
|
||||||
|
<button class="clicd-nat-btn" type="button" data-clicd-action="add">添加</button>
|
||||||
|
<button class="clicd-nat-btn clicd-nat-btn-secondary" type="button" data-clicd-action="random-port">获取随机端口</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="clicd-nat-title">现有端口映射</div>
|
||||||
|
<div id="clicd-nat-list" class="clicd-nat-list">
|
||||||
|
{if condition="empty($port_mappings)"}
|
||||||
|
<div class="clicd-nat-form clicd-nat-muted">暂无端口映射</div>
|
||||||
|
{else/}
|
||||||
|
{foreach name="port_mappings" item="mapping"}
|
||||||
|
<div class="clicd-nat-item" data-index="{$mapping.index}">
|
||||||
|
<div class="clicd-nat-row">
|
||||||
|
<div class="clicd-nat-field">
|
||||||
|
<label>索引</label>
|
||||||
|
<div class="clicd-nat-value">{$mapping.index}</div>
|
||||||
|
</div>
|
||||||
|
<div class="clicd-nat-field">
|
||||||
|
<label>公网访问</label>
|
||||||
|
<div class="clicd-nat-value">{$nat_host}:{$mapping.host_port}</div>
|
||||||
|
</div>
|
||||||
|
<div class="clicd-nat-field">
|
||||||
|
<label>公网端口</label>
|
||||||
|
<input class="clicd-nat-input" data-field="host_port" type="number" min="1" max="65535" value="{$mapping.host_port}">
|
||||||
|
</div>
|
||||||
|
<div class="clicd-nat-field">
|
||||||
|
<label>容器端口</label>
|
||||||
|
<input class="clicd-nat-input" data-field="container_port" type="number" min="1" max="65535" value="{$mapping.container_port}">
|
||||||
|
</div>
|
||||||
|
<div class="clicd-nat-field">
|
||||||
|
<label>协议</label>
|
||||||
|
<select class="clicd-nat-select" data-field="protocol">
|
||||||
|
<option value="tcp" {$mapping.tcp_selected}>TCP</option>
|
||||||
|
<option value="udp" {$mapping.udp_selected}>UDP</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="clicd-nat-field">
|
||||||
|
<label>说明</label>
|
||||||
|
<input class="clicd-nat-input" data-field="description" type="text" value="{$mapping.description}">
|
||||||
|
</div>
|
||||||
|
<div class="clicd-nat-actions">
|
||||||
|
<button class="clicd-nat-btn clicd-nat-btn-secondary" type="button" data-clicd-action="update">保存</button>
|
||||||
|
<button class="clicd-nat-btn clicd-nat-btn-danger" type="button" data-clicd-action="delete">删除</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/foreach}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<pre class="clicd-nat-debug" id="clicd-nat-debug"></pre>
|
||||||
|
<div class="clicd-nat-modal-mask" id="clicd-nat-delete-modal">
|
||||||
|
<div class="clicd-nat-modal">
|
||||||
|
<div class="clicd-nat-modal-title">确认删除</div>
|
||||||
|
<div class="clicd-nat-modal-body" id="clicd-nat-delete-text">确认删除该端口映射?</div>
|
||||||
|
<div class="clicd-nat-modal-actions">
|
||||||
|
<button class="clicd-nat-btn clicd-nat-btn-secondary" type="button" id="clicd-nat-delete-cancel">取消</button>
|
||||||
|
<button class="clicd-nat-btn clicd-nat-btn-danger" type="button" id="clicd-nat-delete-confirm">删除</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
var panel = document.getElementById('clicd-nat-panel');
|
||||||
|
if (!panel || panel.getAttribute('data-bound') === '1') return;
|
||||||
|
panel.setAttribute('data-bound', '1');
|
||||||
|
|
||||||
|
var message = document.getElementById('clicd-nat-message');
|
||||||
|
var debugBox = document.getElementById('clicd-nat-debug');
|
||||||
|
var list = document.getElementById('clicd-nat-list');
|
||||||
|
var natHost = '{$nat_host}';
|
||||||
|
var deleteModal = document.getElementById('clicd-nat-delete-modal');
|
||||||
|
var deleteText = document.getElementById('clicd-nat-delete-text');
|
||||||
|
var deleteCancel = document.getElementById('clicd-nat-delete-cancel');
|
||||||
|
var deleteConfirm = document.getElementById('clicd-nat-delete-confirm');
|
||||||
|
var pendingDeletePayload = null;
|
||||||
|
|
||||||
|
function showMessage(type, text) {
|
||||||
|
message.className = 'clicd-nat-message' + (type === 'error' ? ' error' : '');
|
||||||
|
message.style.display = 'block';
|
||||||
|
message.textContent = text || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function showDebug(data) {
|
||||||
|
debugBox.style.display = 'block';
|
||||||
|
debugBox.textContent = JSON.stringify(data || {}, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function endpoint() {
|
||||||
|
return "{$MODULE_CUSTOM_API}";
|
||||||
|
}
|
||||||
|
|
||||||
|
function field(item, name) {
|
||||||
|
return item.querySelector('[data-field="' + name + '"]');
|
||||||
|
}
|
||||||
|
|
||||||
|
function setBusy(busy) {
|
||||||
|
panel.querySelectorAll('button').forEach(function(btn){ btn.disabled = !!busy; });
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderList(items) {
|
||||||
|
if (!Array.isArray(items) || items.length === 0) {
|
||||||
|
list.innerHTML = '<div class="clicd-nat-form clicd-nat-muted">暂无端口映射</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
list.innerHTML = items.map(function(item) {
|
||||||
|
var protocol = (item.protocol || 'tcp').toLowerCase();
|
||||||
|
var desc = escapeHtml(item.description || '');
|
||||||
|
var index = escapeHtml(String(item.index));
|
||||||
|
var hostPort = escapeHtml(String(item.host_port || ''));
|
||||||
|
var containerPort = escapeHtml(String(item.container_port || ''));
|
||||||
|
return '<div class="clicd-nat-item" data-index="' + index + '">' +
|
||||||
|
'<div class="clicd-nat-row">' +
|
||||||
|
'<div class="clicd-nat-field"><label>索引</label><div class="clicd-nat-value">' + index + '</div></div>' +
|
||||||
|
'<div class="clicd-nat-field"><label>公网访问</label><div class="clicd-nat-value">' + escapeHtml(natHost) + ':' + hostPort + '</div></div>' +
|
||||||
|
'<div class="clicd-nat-field"><label>公网端口</label><input class="clicd-nat-input" data-field="host_port" type="number" min="1" max="65535" value="' + hostPort + '"></div>' +
|
||||||
|
'<div class="clicd-nat-field"><label>容器端口</label><input class="clicd-nat-input" data-field="container_port" type="number" min="1" max="65535" value="' + containerPort + '"></div>' +
|
||||||
|
'<div class="clicd-nat-field"><label>协议</label><select class="clicd-nat-select" data-field="protocol">' +
|
||||||
|
'<option value="tcp"' + (protocol === 'tcp' ? ' selected' : '') + '>TCP</option>' +
|
||||||
|
'<option value="udp"' + (protocol === 'udp' ? ' selected' : '') + '>UDP</option>' +
|
||||||
|
'</select></div>' +
|
||||||
|
'<div class="clicd-nat-field"><label>说明</label><input class="clicd-nat-input" data-field="description" type="text" value="' + desc + '"></div>' +
|
||||||
|
'<div class="clicd-nat-actions"><button class="clicd-nat-btn clicd-nat-btn-secondary" type="button" data-clicd-action="update">保存</button>' +
|
||||||
|
'<button class="clicd-nat-btn clicd-nat-btn-danger" type="button" data-clicd-action="delete">删除</button></div>' +
|
||||||
|
'</div></div>';
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request(action, payload, silent) {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
var funcMap = {
|
||||||
|
'random-port': 'randomPort',
|
||||||
|
'add': 'addNat',
|
||||||
|
'update': 'updateNat',
|
||||||
|
'delete': 'deleteNat',
|
||||||
|
'list': 'natList'
|
||||||
|
};
|
||||||
|
var body = new URLSearchParams();
|
||||||
|
body.set('id', panel.getAttribute('data-service-id') || '');
|
||||||
|
body.set('func', funcMap[action] || action);
|
||||||
|
Object.keys(payload || {}).forEach(function(key){ body.set(key, payload[key]); });
|
||||||
|
var res = await fetch(endpoint(), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
|
||||||
|
'Authorization': 'JWT {$Think.get.jwt}'
|
||||||
|
},
|
||||||
|
credentials: 'same-origin',
|
||||||
|
body: body.toString()
|
||||||
|
});
|
||||||
|
var text = await res.text();
|
||||||
|
var data;
|
||||||
|
try { data = JSON.parse(text); } catch (e) { data = {status:'error', msg:'\u975e JSON \u54cd\u5e94: ' + text}; }
|
||||||
|
showDebug((data.data && data.data.debug) || data.debug || data);
|
||||||
|
if (data.status === 200 || data.status === 'success') {
|
||||||
|
if (!silent) {
|
||||||
|
showMessage('success', data.msg || '\u64cd\u4f5c\u6210\u529f');
|
||||||
|
}
|
||||||
|
if (data.data && data.data.port) {
|
||||||
|
document.getElementById('clicd-add-host-port').value = data.data.port;
|
||||||
|
}
|
||||||
|
if (data.data && Array.isArray(data.data.port_mappings)) {
|
||||||
|
renderList(data.data.port_mappings);
|
||||||
|
} else if (action !== 'random-port') {
|
||||||
|
request('list', {}, true);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
showMessage('error', data.msg || '\u64cd\u4f5c\u5931\u8d25');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showMessage('error', e.message || '\u8bf7\u6c42\u5931\u8d25');
|
||||||
|
showDebug({error: String(e)});
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDeleteModal(payload) {
|
||||||
|
pendingDeletePayload = payload;
|
||||||
|
if (deleteText) {
|
||||||
|
deleteText.textContent = '\u786e\u8ba4\u5220\u9664\u7aef\u53e3\u6620\u5c04 ' + natHost + ':' + (payload.host_port || '-') + ' -> ' + (payload.container_port || '-') + '/' + (payload.protocol || 'tcp') + ' \u5417\uff1f';
|
||||||
|
}
|
||||||
|
if (deleteModal) {
|
||||||
|
deleteModal.style.display = 'flex';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDeleteModal() {
|
||||||
|
pendingDeletePayload = null;
|
||||||
|
if (deleteModal) {
|
||||||
|
deleteModal.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
panel.addEventListener('click', function(event) {
|
||||||
|
var button = event.target.closest('[data-clicd-action]');
|
||||||
|
if (!button) return;
|
||||||
|
var action = button.getAttribute('data-clicd-action');
|
||||||
|
if (action === 'random-port') {
|
||||||
|
request('random-port', {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action === 'add') {
|
||||||
|
request('add', {
|
||||||
|
host_port: document.getElementById('clicd-add-host-port').value,
|
||||||
|
container_port: document.getElementById('clicd-add-container-port').value,
|
||||||
|
protocol: document.getElementById('clicd-add-protocol').value,
|
||||||
|
description: document.getElementById('clicd-add-description').value
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var item = button.closest('.clicd-nat-item');
|
||||||
|
if (!item) return;
|
||||||
|
var payload = {
|
||||||
|
index: item.getAttribute('data-index'),
|
||||||
|
host_port: field(item, 'host_port') ? field(item, 'host_port').value : '',
|
||||||
|
container_port: field(item, 'container_port') ? field(item, 'container_port').value : '',
|
||||||
|
protocol: field(item, 'protocol') ? field(item, 'protocol').value : 'tcp',
|
||||||
|
description: field(item, 'description') ? field(item, 'description').value : ''
|
||||||
|
};
|
||||||
|
if (action === 'delete') {
|
||||||
|
openDeleteModal(payload);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
request(action, payload);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (deleteCancel) {
|
||||||
|
deleteCancel.addEventListener('click', closeDeleteModal);
|
||||||
|
}
|
||||||
|
if (deleteModal) {
|
||||||
|
deleteModal.addEventListener('click', function(event){
|
||||||
|
if (event.target === deleteModal) closeDeleteModal();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (deleteConfirm) {
|
||||||
|
deleteConfirm.addEventListener('click', function(){
|
||||||
|
if (!pendingDeletePayload) return;
|
||||||
|
var payload = pendingDeletePayload;
|
||||||
|
closeDeleteModal();
|
||||||
|
request('delete', payload);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
@@ -10,14 +10,42 @@
|
|||||||
<img alt="TypeScript" src="https://img.shields.io/badge/TypeScript-5-3178C6?style=flat-square&logo=typescript&logoColor=white">
|
<img alt="TypeScript" src="https://img.shields.io/badge/TypeScript-5-3178C6?style=flat-square&logo=typescript&logoColor=white">
|
||||||
<img alt="Vite" src="https://img.shields.io/badge/Vite-5-646CFF?style=flat-square&logo=vite&logoColor=white">
|
<img alt="Vite" src="https://img.shields.io/badge/Vite-5-646CFF?style=flat-square&logo=vite&logoColor=white">
|
||||||
<img alt="Tailwind CSS" src="https://img.shields.io/badge/Tailwind_CSS-3-06B6D4?style=flat-square&logo=tailwindcss&logoColor=white">
|
<img alt="Tailwind CSS" src="https://img.shields.io/badge/Tailwind_CSS-3-06B6D4?style=flat-square&logo=tailwindcss&logoColor=white">
|
||||||
<img alt="LXC" src="https://img.shields.io/badge/LXC-container-111111?style=flat-square">
|
<img alt="LXC" src="https://img.shields.io/badge/LXC-Supported-111111?style=flat-square">
|
||||||
<img alt="KVM" src="https://img.shields.io/badge/KVM-virtualization-EE0000?style=flat-square&logo=linux&logoColor=white">
|
<img alt="KVM" src="https://img.shields.io/badge/KVM-Supported-EE0000?style=flat-square">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img alt="WebSSH" src="https://img.shields.io/badge/WebSSH-Built--in-009688?style=flat-square">
|
||||||
|
<img alt="VNC" src="https://img.shields.io/badge/VNC-Supported-7B1FA2?style=flat-square">
|
||||||
|
<img alt="IPv6" src="https://img.shields.io/badge/IPv6-Native-1976D2?style=flat-square">
|
||||||
|
<img alt="NAT" src="https://img.shields.io/badge/NAT-Port_Forwarding-FF9800?style=flat-square">
|
||||||
|
<img alt="REST API" src="https://img.shields.io/badge/API-REST-4CAF50?style=flat-square">
|
||||||
|
<img alt="Multi User" src="https://img.shields.io/badge/Multi_User-Supported-8E24AA?style=flat-square">
|
||||||
|
<img alt="Traffic Control" src="https://img.shields.io/badge/Traffic-Control-795548?style=flat-square">
|
||||||
|
<img alt="Security Alert" src="https://img.shields.io/badge/Security-Alert-orange?style=flat-square">
|
||||||
|
<img alt="CLI" src="https://img.shields.io/badge/CLI-Mode-424242?style=flat-square">
|
||||||
|
<img alt="TLS" src="https://img.shields.io/badge/TLS-Let's_Encrypt-003A70?style=flat-square&logo=letsencrypt&logoColor=white">
|
||||||
|
</p>
|
||||||
|
|
||||||
|
CLICD is a lightweight virtualization management panel for LXC and KVM, featuring a web console, CLI management, batch operations, image management, NAT networking, IPv6 allocation, WebSSH, VNC access, resource controls, bandwidth limiting, and security alerting.
|
||||||
|
It is designed for managing LXC containers and KVM virtual machines on VPS servers, and is particularly suitable for environments that require bulk provisioning and delegated access management through sub-user management links.
|
||||||
|
|
||||||
CLICD 是一个面向 LXC/KVM 的轻量虚拟化管理面板,提供 Web 控制台、CLI、批量任务、镜像管理、NAT 端口、IPv6 分配、WebSSH、VNC、资源限制、流量限制和安全告警能力。它适合用来管理小型 VPS 上的 LXC 容器和 KVM 虚拟机,也适合需要批量创建和分发子用户管理链接的场景。
|
CLICD 是一个面向 LXC/KVM 的轻量虚拟化管理面板,提供 Web 控制台、CLI、批量任务、镜像管理、NAT 端口、IPv6 分配、WebSSH、VNC、资源限制、流量限制和安全告警能力。它适合用来管理小型 VPS 上的 LXC 容器和 KVM 虚拟机,也适合需要批量创建和分发子用户管理链接的场景。
|
||||||
|
|
||||||
## 功能介绍
|
## Features / 功能介绍
|
||||||
|
|
||||||
|
### English
|
||||||
|
1. Supports Ubuntu, Debian, Alpine, CentOS, Arch Linux, Fedora, Rocky Linux, and other operating system images. Images can be downloaded on demand through the image management interface. For hosts with limited resources, lightweight distributions such as Alpine are recommended.
|
||||||
|
2. Supports WebSSH management, allowing users to access container terminals directly from the browser without manually copying SSH credentials.
|
||||||
|
3. Supports NAT4 port quotas, port forwarding, and protocol restrictions, as well as public IPv6 allocation. IPv6 assignment requires the host machine to have a routable IPv6 prefix.
|
||||||
|
4. Supports both inbound and outbound traffic limits. Containers are automatically powered off when configured limits are reached, preventing bandwidth overuse.
|
||||||
|
5. Supports container expiration dates. Expired containers are automatically shut down, and delegated users lose access until an administrator extends the expiration period.
|
||||||
|
6. Includes lightweight conntrack-based security monitoring. The system does not store full logs of normal connections, but generates audit alerts for suspicious activities such as port scanning, lateral scanning, brute-force attempts, SMTP abuse, UDP reflection attacks, cryptocurrency mining ports, and proxy/VPN/Tor usage.
|
||||||
|
7. Supports delegated management links. Administrators can assign specific containers to sub-users, while ensuring that each user can only manage the containers explicitly authorized to them.
|
||||||
|
8. Provides a REST API for automating the management of containers, tasks, images, networking, traffic controls, and security alerts.
|
||||||
|
9. Supports operating entirely through the CLI. When the web console is not required, administrators can stop and disable the systemd service and launch CLI-only mode using `clicd cli --no-web`.
|
||||||
|
|
||||||
|
### 中文
|
||||||
1. 支持 Ubuntu、Debian、Alpine、CentOS、Arch Linux、Fedora、Rocky Linux 等系统镜像。镜像可以在镜像管理中按需下载;如果宿主机资源比较小,建议优先选择 Alpine 这类轻量镜像。
|
1. 支持 Ubuntu、Debian、Alpine、CentOS、Arch Linux、Fedora、Rocky Linux 等系统镜像。镜像可以在镜像管理中按需下载;如果宿主机资源比较小,建议优先选择 Alpine 这类轻量镜像。
|
||||||
2. 支持 WebSSH 管理,可以在浏览器里一键进入容器终端,不需要手动复制 SSH 密码。
|
2. 支持 WebSSH 管理,可以在浏览器里一键进入容器终端,不需要手动复制 SSH 密码。
|
||||||
3. 支持设置 NAT4 端口数量、NAT 端口映射和协议限制,并支持分配公网 IPv6。IPv6 分配要求宿主机本身拥有可路由的 IPv6 地址段。
|
3. 支持设置 NAT4 端口数量、NAT 端口映射和协议限制,并支持分配公网 IPv6。IPv6 分配要求宿主机本身拥有可路由的 IPv6 地址段。
|
||||||
@@ -28,22 +56,22 @@ CLICD 是一个面向 LXC/KVM 的轻量虚拟化管理面板,提供 Web 控制
|
|||||||
8. 支持 API 接入,可以通过 API 完成容器、任务、镜像、端口、流量、安全告警等功能的自动化控制。
|
8. 支持 API 接入,可以通过 API 完成容器、任务、镜像、端口、流量、安全告警等功能的自动化控制。
|
||||||
9. 支持仅使用 CLI 管理。需要关闭 Web 控制台时,可以停止并禁用 systemd 服务,然后使用 `clicd cli --no-web` 进入命令行模式。
|
9. 支持仅使用 CLI 管理。需要关闭 Web 控制台时,可以停止并禁用 systemd 服务,然后使用 `clicd cli --no-web` 进入命令行模式。
|
||||||
|
|
||||||
## 技术栈
|
## Technology Stack / 技术栈
|
||||||
|
|
||||||
- Backend: Go, net/http, LXC, KVM/libvirt, cgroup v2, iptables, conntrack
|
- Backend: Go, net/http, LXC, KVM/libvirt, cgroup v2, iptables, conntrack
|
||||||
- Frontend: React, TypeScript, Vite, Tailwind CSS, lucide-react, xterm.js
|
- Frontend: React, TypeScript, Vite, Tailwind CSS, lucide-react, xterm.js
|
||||||
- Runtime: Linux, systemd, LXC, KVM/QEMU
|
- Runtime: Linux, systemd, LXC, KVM/QEMU
|
||||||
- Build: GitHub Actions, Node.js 20, Go 1.22
|
- Build: GitHub Actions, Node.js 20, Go 1.22
|
||||||
|
|
||||||
## 安装
|
## Installation / 安装
|
||||||
|
|
||||||
一键安装:
|
One-click Install / 一键安装:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -fsSL https://raw.githubusercontent.com/MengMengCode/CLICD/main/install.sh | sudo sh
|
curl -fsSL https://raw.githubusercontent.com/MengMengCode/CLICD/main/install.sh | sudo sh
|
||||||
```
|
```
|
||||||
|
|
||||||
一键卸载:
|
One-click Uninstall / 一键卸载:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -fsSL https://raw.githubusercontent.com/MengMengCode/CLICD/main/install.sh | sudo sh -s -- uninstall
|
curl -fsSL https://raw.githubusercontent.com/MengMengCode/CLICD/main/install.sh | sudo sh -s -- uninstall
|
||||||
@@ -83,4 +111,4 @@ This open-source software is intended solely for educational purposes, specifica
|
|||||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=MengMengCode/CLICD&type=date&legend=top-left" />
|
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=MengMengCode/CLICD&type=date&legend=top-left" />
|
||||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=MengMengCode/CLICD&type=date&legend=top-left" />
|
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=MengMengCode/CLICD&type=date&legend=top-left" />
|
||||||
</picture>
|
</picture>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
+272
-106
@@ -8,7 +8,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -18,66 +17,100 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type ApiKey struct {
|
type ApiKey struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Key string `json:"key,omitempty"`
|
Key string `json:"key,omitempty"`
|
||||||
Prefix string `json:"prefix"`
|
Prefix string `json:"prefix"`
|
||||||
IPWhitelist string `json:"ip_whitelist"`
|
IPWhitelist string `json:"ip_whitelist"`
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
LastUsed string `json:"last_used"`
|
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
|
// HandleApiKeys handles GET (list) and POST (create) for API keys
|
||||||
func HandleApiKeys(w http.ResponseWriter, r *http.Request) {
|
func HandleApiKeys(w http.ResponseWriter, r *http.Request) {
|
||||||
switch r.Method {
|
switch r.Method {
|
||||||
case http.MethodGet:
|
case http.MethodGet:
|
||||||
|
if !requireScope(w, r, "apikey:read") {
|
||||||
|
return
|
||||||
|
}
|
||||||
listApiKeys(w, r)
|
listApiKeys(w, r)
|
||||||
case http.MethodPost:
|
case http.MethodPost:
|
||||||
|
if !requireScope(w, r, "apikey:create") {
|
||||||
|
return
|
||||||
|
}
|
||||||
createApiKey(w, r)
|
createApiKey(w, r)
|
||||||
default:
|
default:
|
||||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
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) {
|
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"})
|
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"})
|
func apiKeyIDFromPath(path string) string {
|
||||||
return
|
path = strings.TrimPrefix(path, "/api/api-keys/")
|
||||||
}
|
path = strings.TrimPrefix(path, "/api/v1/api-keys/")
|
||||||
config.DeleteApiKey(keyID)
|
return strings.Trim(path, "/")
|
||||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "API key deleted"})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func listApiKeys(w http.ResponseWriter, r *http.Request) {
|
func listApiKeys(w http.ResponseWriter, r *http.Request) {
|
||||||
keys := make([]ApiKey, 0)
|
keys := make([]ApiKey, 0)
|
||||||
for _, k := range config.AppConfig.ApiKeys {
|
for _, k := range config.AppConfig.ApiKeys {
|
||||||
keys = append(keys, ApiKey{
|
keys = append(keys, apiKeyResponse(k))
|
||||||
ID: k.ID,
|
|
||||||
Name: k.Name,
|
|
||||||
Prefix: k.Prefix,
|
|
||||||
IPWhitelist: k.IPWhitelist,
|
|
||||||
CreatedAt: k.CreatedAt,
|
|
||||||
LastUsed: k.LastUsed,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: keys})
|
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: keys})
|
||||||
}
|
}
|
||||||
|
|
||||||
func createApiKey(w http.ResponseWriter, r *http.Request) {
|
func createApiKey(w http.ResponseWriter, r *http.Request) {
|
||||||
var req struct {
|
var req apiKeyRequest
|
||||||
Name string `json:"name"`
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.Name) == "" {
|
||||||
IPWhitelist string `json:"ip_whitelist"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Name == "" {
|
|
||||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Name is required"})
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Name is required"})
|
||||||
return
|
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
|
// Generate key: clicd_sk_ + 32 hex chars
|
||||||
rawBytes := make([]byte, 16)
|
rawBytes := make([]byte, 16)
|
||||||
@@ -94,31 +127,109 @@ func createApiKey(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
now := time.Now().Format("2006-01-02 15:04:05")
|
now := time.Now().Format("2006-01-02 15:04:05")
|
||||||
|
scopes := normalizeRequestedScopes(req.Scopes, defaultApiKeyScopes)
|
||||||
key := config.ApiKeyConfig{
|
key := config.ApiKeyConfig{
|
||||||
ID: generateShortID(),
|
ID: generateShortID(),
|
||||||
Name: req.Name,
|
Name: strings.TrimSpace(req.Name),
|
||||||
KeyHash: keyHash,
|
KeyHash: keyHash,
|
||||||
Prefix: rawKey[:13] + "...",
|
Prefix: rawKey[:13] + "...",
|
||||||
IPWhitelist: strings.TrimSpace(req.IPWhitelist),
|
IPWhitelist: strings.TrimSpace(req.IPWhitelist),
|
||||||
CreatedAt: now,
|
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.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{
|
jsonResponse(w, http.StatusCreated, APIResponse{
|
||||||
Success: true,
|
Success: true,
|
||||||
Message: "API key created. Save this key now - it won't be shown again.",
|
Message: "API key created. Save this key now - it won't be shown again.",
|
||||||
Data: ApiKey{
|
Data: resp,
|
||||||
ID: key.ID,
|
|
||||||
Name: key.Name,
|
|
||||||
Key: rawKey,
|
|
||||||
Prefix: key.Prefix,
|
|
||||||
IPWhitelist: key.IPWhitelist,
|
|
||||||
CreatedAt: key.CreatedAt,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
func generateShortID() string {
|
||||||
b := make([]byte, 4)
|
b := make([]byte, 4)
|
||||||
rand.Read(b)
|
rand.Read(b)
|
||||||
@@ -203,13 +314,21 @@ func matchApiKey(rawKey string) (idx int, needsRehash bool) {
|
|||||||
|
|
||||||
// validateApiKey checks if the given key is valid and IP is allowed.
|
// validateApiKey checks if the given key is valid and IP is allowed.
|
||||||
func validateApiKey(rawKey, clientIP string) bool {
|
func validateApiKey(rawKey, clientIP string) bool {
|
||||||
|
_, ok := validateApiKeyDetails(rawKey, clientIP)
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateApiKeyDetails(rawKey, clientIP string) (*config.ApiKeyConfig, bool) {
|
||||||
idx, needsRehash := matchApiKey(rawKey)
|
idx, needsRehash := matchApiKey(rawKey)
|
||||||
if idx < 0 {
|
if idx < 0 {
|
||||||
return false
|
return nil, false
|
||||||
}
|
}
|
||||||
k := config.AppConfig.ApiKeys[idx]
|
k := &config.AppConfig.ApiKeys[idx]
|
||||||
if k.IPWhitelist != "" && !isIPAllowed(clientIP, k.IPWhitelist) {
|
if k.Disabled || apiKeyExpired(k.ExpiresAt) {
|
||||||
return false
|
return nil, false
|
||||||
|
}
|
||||||
|
if clientIP != "" && k.IPWhitelist != "" && !isIPAllowed(clientIP, k.IPWhitelist) {
|
||||||
|
return nil, false
|
||||||
}
|
}
|
||||||
if needsRehash {
|
if needsRehash {
|
||||||
if newHash, err := hashAPIKey(rawKey); err == nil {
|
if newHash, err := hashAPIKey(rawKey); err == nil {
|
||||||
@@ -217,7 +336,38 @@ func validateApiKey(rawKey, clientIP string) bool {
|
|||||||
config.SaveConfig()
|
config.SaveConfig()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return true
|
if len(k.Scopes) == 0 {
|
||||||
|
k.Scopes = []string{"*"}
|
||||||
|
}
|
||||||
|
return k, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateApiKeyRequest(r *http.Request) (*config.ApiKeyConfig, bool) {
|
||||||
|
apiKey := apiKeyFromRequest(r)
|
||||||
|
if apiKey == "" {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
key, ok := validateApiKeyDetails(apiKey, clientIP(r))
|
||||||
|
if !ok {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
updateApiKeyLastUsedForKey(key, clientIP(r))
|
||||||
|
return key, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func authContextFromAPIKey(key *config.ApiKeyConfig) AuthContext {
|
||||||
|
actor := "api:" + key.ID
|
||||||
|
if key.Name != "" {
|
||||||
|
actor = "api:" + key.Name
|
||||||
|
}
|
||||||
|
return AuthContext{
|
||||||
|
Type: authTypeAPIKey,
|
||||||
|
ApiKeyID: key.ID,
|
||||||
|
ApiKeyName: key.Name,
|
||||||
|
Actor: actor,
|
||||||
|
Scopes: normalizeApiKeyScopes(key.Scopes),
|
||||||
|
ContainerUUIDs: key.ContainerUUIDs,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func apiKeyFromRequest(r *http.Request) string {
|
func apiKeyFromRequest(r *http.Request) string {
|
||||||
@@ -232,23 +382,16 @@ func apiKeyFromRequest(r *http.Request) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func isValidApiKeyRequest(r *http.Request) bool {
|
func isValidApiKeyRequest(r *http.Request) bool {
|
||||||
apiKey := apiKeyFromRequest(r)
|
_, ok := validateApiKeyRequest(r)
|
||||||
if apiKey == "" {
|
return ok
|
||||||
return false
|
|
||||||
}
|
|
||||||
if !validateApiKey(apiKey, clientIP(r)) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
updateApiKeyLastUsed(apiKey)
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// isIPAllowed checks if clientIP matches any entry in the whitelist
|
// isIPAllowed checks if clientIP matches any entry in the whitelist
|
||||||
func isIPAllowed(clientIP, whitelist string) bool {
|
func isIPAllowed(clientIP, whitelist string) bool {
|
||||||
clientIP = strings.TrimSpace(clientIP)
|
clientIP = normalizeIPString(clientIP)
|
||||||
// Strip port if present
|
client := net.ParseIP(clientIP)
|
||||||
if idx := strings.LastIndex(clientIP, ":"); idx > strings.LastIndex(clientIP, "]") {
|
if client == nil {
|
||||||
clientIP = clientIP[:idx]
|
return false
|
||||||
}
|
}
|
||||||
for _, entry := range strings.Split(whitelist, "\n") {
|
for _, entry := range strings.Split(whitelist, "\n") {
|
||||||
entry = strings.TrimSpace(entry)
|
entry = strings.TrimSpace(entry)
|
||||||
@@ -256,74 +399,97 @@ func isIPAllowed(clientIP, whitelist string) bool {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if strings.Contains(entry, "/") {
|
if strings.Contains(entry, "/") {
|
||||||
// CIDR match
|
_, network, err := net.ParseCIDR(entry)
|
||||||
if ipInCIDR(clientIP, entry) {
|
if err == nil && network.Contains(client) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
} else if entry == clientIP {
|
continue
|
||||||
|
}
|
||||||
|
if allowed := net.ParseIP(normalizeIPString(entry)); allowed != nil && allowed.Equal(client) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func ipInCIDR(ipStr, cidr string) bool {
|
func normalizeIPString(s string) string {
|
||||||
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 {
|
|
||||||
s = strings.TrimSpace(s)
|
s = strings.TrimSpace(s)
|
||||||
if idx := strings.LastIndex(s, ":"); idx > strings.LastIndex(s, "]") {
|
if host, _, err := net.SplitHostPort(s); err == nil {
|
||||||
s = s[:idx]
|
return strings.Trim(host, "[]")
|
||||||
}
|
}
|
||||||
return net.ParseIP(s)
|
return strings.Trim(s, "[]")
|
||||||
}
|
}
|
||||||
|
|
||||||
func ip4ToUint32(ip net.IP) uint32 {
|
func ipInCIDR(ipStr, cidr string) bool {
|
||||||
ip = ip.To4()
|
ip := net.ParseIP(normalizeIPString(ipStr))
|
||||||
if ip == nil {
|
_, network, err := net.ParseCIDR(cidr)
|
||||||
return 0
|
return err == nil && ip != nil && network.Contains(ip)
|
||||||
}
|
|
||||||
return uint32(ip[0])<<24 | uint32(ip[1])<<16 | uint32(ip[2])<<8 | uint32(ip[3])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// updateApiKeyLastUsed marks the key as recently used.
|
// updateApiKeyLastUsed marks the key as recently used.
|
||||||
func updateApiKeyLastUsed(rawKey string) {
|
func updateApiKeyLastUsed(rawKey string) {
|
||||||
idx, _ := matchApiKey(rawKey)
|
key, ok := validateApiKeyDetails(rawKey, "")
|
||||||
if idx < 0 {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
config.AppConfig.ApiKeys[idx].LastUsed = time.Now().Format("2006-01-02 15:04:05")
|
updateApiKeyLastUsedForKey(key, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateApiKeyLastUsedForKey(key *config.ApiKeyConfig, ip string) {
|
||||||
|
key.LastUsed = time.Now().Format("2006-01-02 15:04:05")
|
||||||
|
if ip != "" {
|
||||||
|
key.LastUsedIP = ip
|
||||||
|
}
|
||||||
config.SaveConfig()
|
config.SaveConfig()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ApiKeyMiddleware authenticates requests via X-API-Key header or Authorization bearer.
|
// ApiKeyMiddleware authenticates requests via X-API-Key header or Authorization bearer.
|
||||||
func ApiKeyMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
func ApiKeyMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
apiKey := apiKeyFromRequest(r)
|
key, ok := validateApiKeyRequest(r)
|
||||||
if apiKey == "" || !validateApiKey(apiKey, clientIP(r)) {
|
if !ok {
|
||||||
jsonResponse(w, http.StatusUnauthorized, APIResponse{Success: false, Message: "Invalid API key or IP not in whitelist"})
|
jsonResponse(w, http.StatusUnauthorized, APIResponse{Success: false, Message: "Invalid API key or IP not in whitelist"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
next(w, withAuthContext(r, authContextFromAPIKey(key)))
|
||||||
updateApiKeyLastUsed(apiKey)
|
|
||||||
next(w, r)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeApiKeyScopes(scopes []string) []string {
|
||||||
|
return normalizeRequestedScopes(scopes, []string{"*"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeRequestedScopes(scopes []string, fallback []string) []string {
|
||||||
|
result := normalizeStringSlice(scopes)
|
||||||
|
if len(result) == 0 {
|
||||||
|
return append([]string(nil), fallback...)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeStringSlice(values []string) []string {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
result := make([]string, 0, len(values))
|
||||||
|
for _, value := range values {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" || seen[value] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[value] = true
|
||||||
|
result = append(result, value)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func validApiKeyTime(value string) bool {
|
||||||
|
_, err := time.ParseInLocation("2006-01-02 15:04:05", value, time.Local)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func apiKeyExpired(value string) bool {
|
||||||
|
if strings.TrimSpace(value) == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
expiresAt, err := time.ParseInLocation("2006-01-02 15:04:05", value, time.Local)
|
||||||
|
return err == nil && !time.Now().Before(expiresAt)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -28,6 +29,132 @@ type APIResponse struct {
|
|||||||
Data interface{} `json:"data,omitempty"`
|
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) {
|
func jsonResponse(w http.ResponseWriter, status int, resp APIResponse) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(status)
|
w.WriteHeader(status)
|
||||||
@@ -75,8 +202,10 @@ func claimsFromToken(tokenString string) (jwt.MapClaims, bool) {
|
|||||||
if subUser, _ := claims["sub_user"].(string); subUser != "" {
|
if subUser, _ := claims["sub_user"].(string); subUser != "" {
|
||||||
tokenVersionFloat, hasVersion := claims["token_version"].(float64)
|
tokenVersionFloat, hasVersion := claims["token_version"].(float64)
|
||||||
tokenVersion := int(tokenVersionFloat)
|
tokenVersion := int(tokenVersionFloat)
|
||||||
|
foundSubUser := false
|
||||||
for i := range config.AppConfig.SubUsers {
|
for i := range config.AppConfig.SubUsers {
|
||||||
if config.AppConfig.SubUsers[i].Username == subUser {
|
if config.AppConfig.SubUsers[i].Username == subUser {
|
||||||
|
foundSubUser = true
|
||||||
stored := config.AppConfig.SubUsers[i].TokenVersion
|
stored := config.AppConfig.SubUsers[i].TokenVersion
|
||||||
// If stored version > 0, require token_version to match exactly.
|
// If stored version > 0, require token_version to match exactly.
|
||||||
// This also rejects legacy tokens that lack token_version entirely.
|
// This also rejects legacy tokens that lack token_version entirely.
|
||||||
@@ -86,6 +215,9 @@ func claimsFromToken(tokenString string) (jwt.MapClaims, bool) {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if !foundSubUser {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return claims, ok
|
return claims, ok
|
||||||
@@ -96,6 +228,9 @@ func claimsFromRequest(r *http.Request) (jwt.MapClaims, bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func isSubUserRequest(r *http.Request) bool {
|
func isSubUserRequest(r *http.Request) bool {
|
||||||
|
if ctx, ok := authContextFromRequest(r); ok {
|
||||||
|
return ctx.Type == authTypeSubUser
|
||||||
|
}
|
||||||
claims, ok := claimsFromRequest(r)
|
claims, ok := claimsFromRequest(r)
|
||||||
if !ok {
|
if !ok {
|
||||||
return false
|
return false
|
||||||
@@ -210,19 +345,45 @@ func HandleCheckAuth(w http.ResponseWriter, r *http.Request) {
|
|||||||
func AuthMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
func AuthMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
tokenString := tokenFromRequest(r)
|
tokenString := tokenFromRequest(r)
|
||||||
if !isValidToken(tokenString) && !isValidApiKeyRequest(r) {
|
if claims, ok := claimsFromToken(tokenString); ok {
|
||||||
jsonResponse(w, http.StatusUnauthorized, APIResponse{Success: false, Message: "Authentication required"})
|
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
|
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.
|
// AdminMiddleware requires a valid administrator token and rejects sub-user tokens.
|
||||||
func AdminMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
func AdminMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
||||||
return AuthMiddleware(func(w http.ResponseWriter, r *http.Request) {
|
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"})
|
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Administrator permission required"})
|
||||||
return
|
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,12 +2,10 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
"unicode"
|
|
||||||
|
|
||||||
"clicd/internal/config"
|
"clicd/internal/config"
|
||||||
"clicd/internal/lxc"
|
"clicd/internal/lxc"
|
||||||
@@ -20,17 +18,41 @@ var lxcManager = lxc.NewManager()
|
|||||||
func HandleContainers(w http.ResponseWriter, r *http.Request) {
|
func HandleContainers(w http.ResponseWriter, r *http.Request) {
|
||||||
switch r.Method {
|
switch r.Method {
|
||||||
case http.MethodGet:
|
case http.MethodGet:
|
||||||
|
if !requireScope(w, r, "container:read") {
|
||||||
|
return
|
||||||
|
}
|
||||||
listContainers(w, r)
|
listContainers(w, r)
|
||||||
case http.MethodPost:
|
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)
|
createContainer(w, r)
|
||||||
default:
|
default:
|
||||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
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}/...
|
// HandleSingleContainer handles individual container operations by ID or name: /api/containers/{id-or-name}/...
|
||||||
func HandleSingleContainer(w http.ResponseWriter, r *http.Request) {
|
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)
|
parts := strings.SplitN(path, "/", 2)
|
||||||
c := containerByIdentifier(parts[0])
|
c := containerByIdentifier(parts[0])
|
||||||
id := 0
|
id := 0
|
||||||
@@ -50,6 +72,10 @@ func HandleSingleContainer(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"})
|
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"})
|
||||||
return
|
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 {
|
if isSnapshotAction && id == 0 {
|
||||||
// For orphaned snapshots, resolve containerID from the snapshot itself
|
// For orphaned snapshots, resolve containerID from the snapshot itself
|
||||||
snapshotID := strings.TrimPrefix(action, "snapshots/")
|
snapshotID := strings.TrimPrefix(action, "snapshots/")
|
||||||
@@ -61,45 +87,105 @@ func HandleSingleContainer(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
id = snapshot.ContainerID
|
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 {
|
switch {
|
||||||
case action == "start" && r.Method == http.MethodPost:
|
case action == "start" && r.Method == http.MethodPost:
|
||||||
|
if !requireScope(w, r, "container:power") {
|
||||||
|
return
|
||||||
|
}
|
||||||
HandleSingleTaskAction(w, r, id, "start")
|
HandleSingleTaskAction(w, r, id, "start")
|
||||||
case action == "stop" && r.Method == http.MethodPost:
|
case action == "stop" && r.Method == http.MethodPost:
|
||||||
|
if !requireScope(w, r, "container:power") {
|
||||||
|
return
|
||||||
|
}
|
||||||
HandleSingleTaskAction(w, r, id, "stop")
|
HandleSingleTaskAction(w, r, id, "stop")
|
||||||
case action == "restart" && r.Method == http.MethodPost:
|
case action == "restart" && r.Method == http.MethodPost:
|
||||||
|
if !requireScope(w, r, "container:power") {
|
||||||
|
return
|
||||||
|
}
|
||||||
HandleSingleTaskAction(w, r, id, "restart")
|
HandleSingleTaskAction(w, r, id, "restart")
|
||||||
case action == "reinstall" && r.Method == http.MethodPost:
|
case action == "reinstall" && r.Method == http.MethodPost:
|
||||||
|
if !requireScope(w, r, "container:reinstall") {
|
||||||
|
return
|
||||||
|
}
|
||||||
HandleSingleTaskAction(w, r, id, "reinstall")
|
HandleSingleTaskAction(w, r, id, "reinstall")
|
||||||
case action == "delete" && r.Method == http.MethodDelete:
|
case action == "delete" && r.Method == http.MethodDelete:
|
||||||
|
if !requireScope(w, r, "container:delete") {
|
||||||
|
return
|
||||||
|
}
|
||||||
HandleSingleTaskAction(w, r, id, "delete")
|
HandleSingleTaskAction(w, r, id, "delete")
|
||||||
case action == "reset-password" && r.Method == http.MethodPost:
|
case action == "reset-password" && r.Method == http.MethodPost:
|
||||||
|
if !requireScope(w, r, "container:password") {
|
||||||
|
return
|
||||||
|
}
|
||||||
resetSSHPassword(w, r, id)
|
resetSSHPassword(w, r, id)
|
||||||
case action == "usage" && r.Method == http.MethodGet:
|
case action == "usage" && r.Method == http.MethodGet:
|
||||||
|
if !requireScope(w, r, "container:read") {
|
||||||
|
return
|
||||||
|
}
|
||||||
getUsage(w, r, id)
|
getUsage(w, r, id)
|
||||||
case action == "traffic" && r.Method == http.MethodGet:
|
case action == "traffic" && r.Method == http.MethodGet:
|
||||||
|
if !requireScope(w, r, "container:read") {
|
||||||
|
return
|
||||||
|
}
|
||||||
getTraffic(w, r, id)
|
getTraffic(w, r, id)
|
||||||
case action == "traffic-reset" && r.Method == http.MethodPost:
|
case action == "traffic-reset" && r.Method == http.MethodPost:
|
||||||
|
if !requireScope(w, r, "container:traffic") {
|
||||||
|
return
|
||||||
|
}
|
||||||
resetTraffic(w, r, id)
|
resetTraffic(w, r, id)
|
||||||
case action == "traffic-limit" && r.Method == http.MethodPut:
|
case action == "traffic-limit" && r.Method == http.MethodPut:
|
||||||
|
if !requireScope(w, r, "container:traffic") {
|
||||||
|
return
|
||||||
|
}
|
||||||
updateTrafficLimit(w, r, id)
|
updateTrafficLimit(w, r, id)
|
||||||
case action == "resource-limit" && r.Method == http.MethodPut:
|
case action == "resource-limit" && r.Method == http.MethodPut:
|
||||||
|
if !requireScope(w, r, "container:resize") {
|
||||||
|
return
|
||||||
|
}
|
||||||
updateResourceLimit(w, r, id)
|
updateResourceLimit(w, r, id)
|
||||||
case action == "random-port" && r.Method == http.MethodGet:
|
case action == "random-port" && r.Method == http.MethodGet:
|
||||||
|
if !requireScope(w, r, "container:network") {
|
||||||
|
return
|
||||||
|
}
|
||||||
getRandomPort(w, r, id)
|
getRandomPort(w, r, id)
|
||||||
case action == "expiry" && r.Method == http.MethodPut:
|
case action == "expiry" && r.Method == http.MethodPut:
|
||||||
|
if !requireScope(w, r, "container:resize") {
|
||||||
|
return
|
||||||
|
}
|
||||||
updateExpiry(w, r, id)
|
updateExpiry(w, r, id)
|
||||||
case action == "ipv6" && r.Method == http.MethodPost:
|
case action == "ipv6" && r.Method == http.MethodPost:
|
||||||
|
if !requireScope(w, r, "ipv6:assign") {
|
||||||
|
return
|
||||||
|
}
|
||||||
assignIPv6(w, r, id)
|
assignIPv6(w, r, id)
|
||||||
case action == "snapshots" || strings.HasPrefix(action, "snapshots/"):
|
case action == "snapshots" || strings.HasPrefix(action, "snapshots/"):
|
||||||
handleContainerSnapshots(w, r, id, action)
|
handleContainerSnapshots(w, r, id, action)
|
||||||
case action == "port-mappings" && r.Method == http.MethodPost:
|
case action == "port-mappings" && r.Method == http.MethodPost:
|
||||||
|
if !requireScope(w, r, "container:network") {
|
||||||
|
return
|
||||||
|
}
|
||||||
addPortMapping(w, r, id)
|
addPortMapping(w, r, id)
|
||||||
case strings.HasPrefix(action, "port-mappings/") && r.Method == http.MethodPut:
|
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/"))
|
updatePortMapping(w, r, id, strings.TrimPrefix(action, "port-mappings/"))
|
||||||
case strings.HasPrefix(action, "port-mappings/") && r.Method == http.MethodDelete:
|
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/"))
|
deletePortMapping(w, r, id, strings.TrimPrefix(action, "port-mappings/"))
|
||||||
case r.Method == http.MethodGet:
|
case r.Method == http.MethodGet:
|
||||||
|
if !requireScope(w, r, "container:read") {
|
||||||
|
return
|
||||||
|
}
|
||||||
getContainer(w, r, id)
|
getContainer(w, r, id)
|
||||||
default:
|
default:
|
||||||
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Action not found"})
|
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Action not found"})
|
||||||
@@ -140,13 +226,38 @@ func createContainer(w http.ResponseWriter, r *http.Request) {
|
|||||||
if cfg.DiskGB < 1 {
|
if cfg.DiskGB < 1 {
|
||||||
cfg.DiskGB = 5
|
cfg.DiskGB = 5
|
||||||
}
|
}
|
||||||
if cfg.PortMappingCount < 2 {
|
if cfg.PortMappingCount < 0 {
|
||||||
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Port mapping count cannot be negative"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if cfg.WantsNAT() && cfg.PortMappingCount < 2 {
|
||||||
cfg.PortMappingCount = 2
|
cfg.PortMappingCount = 2
|
||||||
|
} else if !cfg.WantsNAT() {
|
||||||
|
cfg.PortMappingCount = 0
|
||||||
|
cfg.ExtraPorts = nil
|
||||||
}
|
}
|
||||||
if cfg.PortMappingCount > 64 {
|
if cfg.PortMappingCount > 64 {
|
||||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Port mapping count cannot exceed 64"})
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Port mapping count cannot exceed 64"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if cfg.IPv4Count < 0 || cfg.IPv6Count < 0 {
|
||||||
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "IP address count cannot be negative"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if cfg.IPv4Count > 64 || cfg.IPv6Count > 64 {
|
||||||
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "IP address count cannot exceed 64"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !cfg.AssignIPv4 && len(cfg.PublicIPv4s) == 0 {
|
||||||
|
cfg.IPv4Count = 0
|
||||||
|
}
|
||||||
|
if !cfg.AssignIPv6 && len(cfg.IPv6Addresses) == 0 {
|
||||||
|
cfg.IPv6Count = 0
|
||||||
|
}
|
||||||
|
if !hasRequestedNetwork(cfg) {
|
||||||
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: noNetworkSelectedMessage})
|
||||||
|
return
|
||||||
|
}
|
||||||
if cfg.SnapshotLimit <= 0 {
|
if cfg.SnapshotLimit <= 0 {
|
||||||
cfg.SnapshotLimit = config.DefaultSnapshotLimit
|
cfg.SnapshotLimit = config.DefaultSnapshotLimit
|
||||||
}
|
}
|
||||||
@@ -154,6 +265,10 @@ func createContainer(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if err := validateCreateSSHAuth(cfg); err != nil {
|
||||||
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
if cfg.ExpiresAt != "" {
|
if cfg.ExpiresAt != "" {
|
||||||
expiresAt, ok := lxc.ParseExpiration(cfg.ExpiresAt)
|
expiresAt, ok := lxc.ParseExpiration(cfg.ExpiresAt)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -317,24 +432,11 @@ func getRandomPort(w http.ResponseWriter, r *http.Request, id int) {
|
|||||||
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"})
|
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Find a random unused port between 10000-65535
|
hostIP := strings.TrimSpace(r.URL.Query().Get("host_ip"))
|
||||||
used := map[int]bool{}
|
|
||||||
for _, pm := range c.PortMappings {
|
|
||||||
used[pm.HostPort] = true
|
|
||||||
}
|
|
||||||
// Also check all containers
|
|
||||||
for _, oc := range config.AppConfig.Containers {
|
|
||||||
if oc.ID == id {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, pm := range oc.PortMappings {
|
|
||||||
used[pm.HostPort] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Try random ports
|
// Try random ports
|
||||||
for tries := 0; tries < 100; tries++ {
|
for tries := 0; tries < 100; tries++ {
|
||||||
port := 10000 + (int(time.Now().UnixNano()) % 55535)
|
port := 10000 + (int(time.Now().UnixNano()) % 55535)
|
||||||
if !used[port] {
|
if lxc.HostPortAvailable(c, hostIP, port, "tcp") {
|
||||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]int{"port": port}})
|
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]int{"port": port}})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -348,6 +450,9 @@ func HandleTemplates(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requireScope(w, r, "image:read") {
|
||||||
|
return
|
||||||
|
}
|
||||||
if isSubUserRequest(r) {
|
if isSubUserRequest(r) {
|
||||||
HandleEnabledImages(w, r)
|
HandleEnabledImages(w, r)
|
||||||
return
|
return
|
||||||
@@ -362,7 +467,11 @@ func HandleDashboard(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requireScope(w, r, "dashboard:read") {
|
||||||
|
return
|
||||||
|
}
|
||||||
containers, _ := listByRuntime()
|
containers, _ := listByRuntime()
|
||||||
|
containers = filterContainersForRequest(r, containers)
|
||||||
running := 0
|
running := 0
|
||||||
stopped := 0
|
stopped := 0
|
||||||
for _, c := range containers {
|
for _, c := range containers {
|
||||||
@@ -386,6 +495,9 @@ func HandleHostInfo(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requireScope(w, r, "host:read") {
|
||||||
|
return
|
||||||
|
}
|
||||||
info := getHostInfo()
|
info := getHostInfo()
|
||||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: info})
|
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: info})
|
||||||
}
|
}
|
||||||
@@ -426,26 +538,7 @@ func resetSSHPassword(w http.ResponseWriter, r *http.Request, id int) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func validateSSHPassword(password string) error {
|
func validateSSHPassword(password string) error {
|
||||||
if len(password) < 8 || len(password) > 64 {
|
return lxc.ValidateCustomSSHPassword(password)
|
||||||
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) {
|
func addPortMapping(w http.ResponseWriter, r *http.Request, id int) {
|
||||||
|
|||||||
+1345
-15
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,43 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestExtractCertbotVersion(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
output string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"certbot 5.4.0", "5.4.0"},
|
||||||
|
{"certbot v5.10.1", "5.10.1"},
|
||||||
|
{"certbot, version 4.9", "4.9"},
|
||||||
|
{"installed", ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
if got := extractCertbotVersion(tt.output); got != tt.want {
|
||||||
|
t.Fatalf("extractCertbotVersion(%q) = %q, want %q", tt.output, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCertbotVersionAtLeast54(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
version string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"5.4", true},
|
||||||
|
{"5.4.0", true},
|
||||||
|
{"5.10", true},
|
||||||
|
{"6.0.0", true},
|
||||||
|
{"5.3.9", false},
|
||||||
|
{"4.99", false},
|
||||||
|
{"5", false},
|
||||||
|
{"", false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
if got := certbotVersionAtLeast(tt.version, 5, 4); got != tt.want {
|
||||||
|
t.Fatalf("certbotVersionAtLeast(%q, 5, 4) = %v, want %v", tt.version, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -221,6 +221,9 @@ func HandleImages(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requireScope(w, r, "image:read") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
enabledSet := getEnabledImageSet()
|
enabledSet := getEnabledImageSet()
|
||||||
cleanupOldImageDownloadErrors()
|
cleanupOldImageDownloadErrors()
|
||||||
@@ -287,6 +290,9 @@ func HandleImageDownload(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requireScope(w, r, "image:download") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var req struct {
|
var req struct {
|
||||||
TemplateID string `json:"template_id"`
|
TemplateID string `json:"template_id"`
|
||||||
@@ -397,6 +403,9 @@ func HandleImageCancel(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requireScope(w, r, "image:download") {
|
||||||
|
return
|
||||||
|
}
|
||||||
var req struct {
|
var req struct {
|
||||||
TemplateID string `json:"template_id"`
|
TemplateID string `json:"template_id"`
|
||||||
}
|
}
|
||||||
@@ -434,6 +443,9 @@ func HandleImageDelete(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requireScope(w, r, "image:delete") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var req struct {
|
var req struct {
|
||||||
TemplateID string `json:"template_id"`
|
TemplateID string `json:"template_id"`
|
||||||
@@ -484,6 +496,9 @@ func HandleImageToggle(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requireScope(w, r, "image:toggle") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var req struct {
|
var req struct {
|
||||||
TemplateID string `json:"template_id"`
|
TemplateID string `json:"template_id"`
|
||||||
@@ -510,6 +525,9 @@ func HandleEnabledImages(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requireScope(w, r, "image:read") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
runtime := runtimeFromRequest(r.URL.Query().Get("type"))
|
runtime := runtimeFromRequest(r.URL.Query().Get("type"))
|
||||||
enabledSet := getEnabledImageSet()
|
enabledSet := getEnabledImageSet()
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ func HandleIPv6Status(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requireScope(w, r, "ipv6:read") {
|
||||||
|
return
|
||||||
|
}
|
||||||
status := lxcManager.DetectIPv6Status()
|
status := lxcManager.DetectIPv6Status()
|
||||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: status})
|
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: status})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"clicd/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
type webSSHOriginSettingsRequest struct {
|
||||||
|
Origins []string `json:"origins"`
|
||||||
|
WebSSHAllowedOrigins []string `json:"webssh_allowed_origins"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type webSSHOriginSettingsResponse struct {
|
||||||
|
Origins []string `json:"origins"`
|
||||||
|
CurrentOrigin string `json:"current_origin,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func HandleWebSSHOriginSettings(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: webSSHOriginSettingsStatus(r)})
|
||||||
|
case http.MethodPut:
|
||||||
|
updateWebSSHOriginSettings(w, r)
|
||||||
|
default:
|
||||||
|
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateWebSSHOriginSettings(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req webSSHOriginSettingsRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
origins := req.Origins
|
||||||
|
if len(origins) == 0 && len(req.WebSSHAllowedOrigins) > 0 {
|
||||||
|
origins = req.WebSSHAllowedOrigins
|
||||||
|
}
|
||||||
|
normalized, err := config.NormalizeAllowedOrigins(origins)
|
||||||
|
if err != nil {
|
||||||
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
config.AppConfig.WebSSHAllowedOrigins = normalized
|
||||||
|
if err := config.SaveConfig(); err != nil {
|
||||||
|
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Save Origin allowlist failed"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
auditRequest(r, "settings.webssh_origins", "WebSSH Origin", "origins="+strings.Join(normalized, ","), true, "")
|
||||||
|
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Origin allowlist saved", Data: webSSHOriginSettingsStatus(r)})
|
||||||
|
}
|
||||||
|
|
||||||
|
func webSSHOriginSettingsStatus(r *http.Request) webSSHOriginSettingsResponse {
|
||||||
|
origins := config.AppConfig.WebSSHAllowedOrigins
|
||||||
|
if origins == nil {
|
||||||
|
origins = []string{}
|
||||||
|
}
|
||||||
|
return webSSHOriginSettingsResponse{
|
||||||
|
Origins: origins,
|
||||||
|
CurrentOrigin: requestOrigin(r),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestOrigin(r *http.Request) string {
|
||||||
|
host := strings.TrimSpace(r.Host)
|
||||||
|
if host == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
scheme := "http"
|
||||||
|
if r.TLS != nil {
|
||||||
|
scheme = "https"
|
||||||
|
}
|
||||||
|
if forwarded := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")); forwarded != "" {
|
||||||
|
scheme = strings.ToLower(strings.Split(forwarded, ",")[0])
|
||||||
|
}
|
||||||
|
return scheme + "://" + host
|
||||||
|
}
|
||||||
+240
-19
@@ -1,7 +1,9 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
@@ -21,12 +23,24 @@ type nat4Route struct {
|
|||||||
LXCName string `json:"lxc_name"`
|
LXCName string `json:"lxc_name"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
IP string `json:"ip"`
|
IP string `json:"ip"`
|
||||||
|
HostIP string `json:"host_ip"`
|
||||||
HostPort int `json:"host_port"`
|
HostPort int `json:"host_port"`
|
||||||
ContainerPort int `json:"container_port"`
|
ContainerPort int `json:"container_port"`
|
||||||
Protocol string `json:"protocol"`
|
Protocol string `json:"protocol"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ipv4Route struct {
|
||||||
|
ContainerID int `json:"container_id"`
|
||||||
|
ContainerName string `json:"container_name"`
|
||||||
|
LXCName string `json:"lxc_name"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Address string `json:"address"`
|
||||||
|
Interface string `json:"interface"`
|
||||||
|
PrefixLen int `json:"prefix_len,omitempty"`
|
||||||
|
Gateway string `json:"gateway,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
type ipv6Route struct {
|
type ipv6Route struct {
|
||||||
ContainerID int `json:"container_id"`
|
ContainerID int `json:"container_id"`
|
||||||
ContainerName string `json:"container_name"`
|
ContainerName string `json:"container_name"`
|
||||||
@@ -38,27 +52,79 @@ type ipv6Route struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type routingResponse struct {
|
type routingResponse struct {
|
||||||
NAT4 routeCapacity `json:"nat4"`
|
NAT4 routeCapacity `json:"nat4"`
|
||||||
IPv6 routeCapacity `json:"ipv6"`
|
IPv4 routeCapacity `json:"ipv4"`
|
||||||
NAT4Mappings []nat4Route `json:"nat4_mappings"`
|
IPv6 routeCapacity `json:"ipv6"`
|
||||||
IPv6Assignments []ipv6Route `json:"ipv6_assignments"`
|
HostPublicIPv4 lxc.PublicIPInfo `json:"host_public_ipv4"`
|
||||||
IPv6Prefixes []lxc.IPv6PrefixInfo `json:"ipv6_prefixes"`
|
PublicIPv4Addresses []lxc.PublicIPInfo `json:"public_ipv4_addresses"`
|
||||||
|
IPv4Assignments []ipv4Route `json:"ipv4_assignments"`
|
||||||
|
NAT4Mappings []nat4Route `json:"nat4_mappings"`
|
||||||
|
IPv6Assignments []ipv6Route `json:"ipv6_assignments"`
|
||||||
|
IPv6Prefixes []lxc.IPv6PrefixInfo `json:"ipv6_prefixes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type routingPoolsRequest struct {
|
||||||
|
Addresses *[]string `json:"addresses"`
|
||||||
|
Items *[]config.PublicIPv4Assignment `json:"items"`
|
||||||
|
IPv6Prefixes *[]config.PublicIPv6Prefix `json:"ipv6_prefixes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type publicIPv4ScanRequest struct {
|
||||||
|
CIDR string `json:"cidr"`
|
||||||
|
Interface string `json:"interface"`
|
||||||
|
Gateway string `json:"gateway"`
|
||||||
|
Verify bool `json:"verify"`
|
||||||
|
Limit int `json:"limit"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func HandleRouting(w http.ResponseWriter, r *http.Request) {
|
func HandleRouting(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodGet {
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
handleRoutingGet(w, r)
|
||||||
|
case http.MethodPut:
|
||||||
|
handleRoutingPoolsUpdate(w, r)
|
||||||
|
default:
|
||||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func HandleRoutingIPv4Scan(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !requireScope(w, r, "routing:write") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req publicIPv4ScanRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
results, err := lxc.ScanPublicIPv4Segment(req.CIDR, req.Interface, req.Gateway, req.Verify, req.Limit)
|
||||||
|
if err != nil {
|
||||||
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: results})
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleRoutingGet(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !hasAnyScope(r, "routing:read", "routing:write") {
|
||||||
|
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Insufficient API key scope"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
nat4Mappings := make([]nat4Route, 0)
|
nat4Mappings := make([]nat4Route, 0)
|
||||||
usedPorts := map[int]bool{}
|
usedPorts := map[int]bool{}
|
||||||
|
ipv4Assignments := make([]ipv4Route, 0)
|
||||||
ipv6Assignments := make([]ipv6Route, 0)
|
ipv6Assignments := make([]ipv6Route, 0)
|
||||||
|
|
||||||
const nat4StartPort = 20000
|
const nat4StartPort = 20000
|
||||||
const nat4EndPort = 65535
|
const nat4EndPort = 65535
|
||||||
|
|
||||||
for _, c := range config.AppConfig.Containers {
|
for i := range config.AppConfig.Containers {
|
||||||
|
c := &config.AppConfig.Containers[i]
|
||||||
for _, pm := range c.PortMappings {
|
for _, pm := range c.PortMappings {
|
||||||
if pm.HostPort >= nat4StartPort && pm.HostPort <= nat4EndPort {
|
if pm.HostPort >= nat4StartPort && pm.HostPort <= nat4EndPort {
|
||||||
usedPorts[pm.HostPort] = true
|
usedPorts[pm.HostPort] = true
|
||||||
@@ -69,30 +135,56 @@ func HandleRouting(w http.ResponseWriter, r *http.Request) {
|
|||||||
LXCName: c.LxcName(),
|
LXCName: c.LxcName(),
|
||||||
Status: c.Status,
|
Status: c.Status,
|
||||||
IP: c.IP,
|
IP: c.IP,
|
||||||
|
HostIP: pm.HostIP,
|
||||||
HostPort: pm.HostPort,
|
HostPort: pm.HostPort,
|
||||||
ContainerPort: pm.ContainerPort,
|
ContainerPort: pm.ContainerPort,
|
||||||
Protocol: pm.Protocol,
|
Protocol: pm.Protocol,
|
||||||
Description: pm.Description,
|
Description: pm.Description,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if c.IPv6 != "" {
|
for _, ip := range c.PublicIPv4s {
|
||||||
|
if ip.Address == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ipv4Assignments = append(ipv4Assignments, ipv4Route{
|
||||||
|
ContainerID: c.ID,
|
||||||
|
ContainerName: c.Name,
|
||||||
|
LXCName: c.LxcName(),
|
||||||
|
Status: c.Status,
|
||||||
|
Address: ip.Address,
|
||||||
|
Interface: ip.Interface,
|
||||||
|
PrefixLen: ip.PrefixLen,
|
||||||
|
Gateway: ip.Gateway,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
c.NormalizeNetworkAssignments()
|
||||||
|
for _, ip := range c.IPv6Addresses {
|
||||||
|
if ip.Address == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
ipv6Assignments = append(ipv6Assignments, ipv6Route{
|
ipv6Assignments = append(ipv6Assignments, ipv6Route{
|
||||||
ContainerID: c.ID,
|
ContainerID: c.ID,
|
||||||
ContainerName: c.Name,
|
ContainerName: c.Name,
|
||||||
LXCName: c.LxcName(),
|
LXCName: c.LxcName(),
|
||||||
Status: c.Status,
|
Status: c.Status,
|
||||||
Address: c.IPv6,
|
Address: ip.Address,
|
||||||
PrefixLen: c.IPv6PrefixLen,
|
PrefixLen: ip.PrefixLen,
|
||||||
Interface: c.IPv6Interface,
|
Interface: ip.Interface,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
sort.SliceStable(nat4Mappings, func(i, j int) bool {
|
sort.SliceStable(nat4Mappings, func(i, j int) bool {
|
||||||
if nat4Mappings[i].HostPort == nat4Mappings[j].HostPort {
|
if nat4Mappings[i].HostPort == nat4Mappings[j].HostPort {
|
||||||
|
if nat4Mappings[i].HostIP != nat4Mappings[j].HostIP {
|
||||||
|
return nat4Mappings[i].HostIP < nat4Mappings[j].HostIP
|
||||||
|
}
|
||||||
return nat4Mappings[i].ContainerName < nat4Mappings[j].ContainerName
|
return nat4Mappings[i].ContainerName < nat4Mappings[j].ContainerName
|
||||||
}
|
}
|
||||||
return nat4Mappings[i].HostPort < nat4Mappings[j].HostPort
|
return nat4Mappings[i].HostPort < nat4Mappings[j].HostPort
|
||||||
})
|
})
|
||||||
|
sort.SliceStable(ipv4Assignments, func(i, j int) bool {
|
||||||
|
return ipv4Assignments[i].Address < ipv4Assignments[j].Address
|
||||||
|
})
|
||||||
sort.SliceStable(ipv6Assignments, func(i, j int) bool {
|
sort.SliceStable(ipv6Assignments, func(i, j int) bool {
|
||||||
return ipv6Assignments[i].Address < ipv6Assignments[j].Address
|
return ipv6Assignments[i].Address < ipv6Assignments[j].Address
|
||||||
})
|
})
|
||||||
@@ -105,12 +197,16 @@ func HandleRouting(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
prefixes := lxc.DetectPublicIPv6Prefixes()
|
prefixes := lxc.DetectPublicIPv6Prefixes()
|
||||||
ipv6Total := "0"
|
hostPublicIPv4 := lxc.DetectPublicIPv4()
|
||||||
ipv6Remaining := "0"
|
publicIPv4s := lxc.DetectPublicIPv4Candidates()
|
||||||
if len(prefixes) > 0 {
|
ipv4Total := len(publicIPv4s)
|
||||||
ipv6Total = lxc.IPv6PrefixCapacity(prefixes[0].PrefixLen)
|
ipv4Used := len(ipv4Assignments)
|
||||||
ipv6Remaining = subtractCapacity(ipv6Total, len(ipv6Assignments))
|
ipv4Remaining := ipv4Total - ipv4Used
|
||||||
|
if ipv4Remaining < 0 {
|
||||||
|
ipv4Remaining = 0
|
||||||
}
|
}
|
||||||
|
ipv6Total := totalIPv6Capacity(prefixes)
|
||||||
|
ipv6Remaining := subtractCapacity(ipv6Total, len(ipv6Assignments))
|
||||||
|
|
||||||
jsonResponse(w, http.StatusOK, APIResponse{
|
jsonResponse(w, http.StatusOK, APIResponse{
|
||||||
Success: true,
|
Success: true,
|
||||||
@@ -120,18 +216,143 @@ func HandleRouting(w http.ResponseWriter, r *http.Request) {
|
|||||||
Remaining: strconv.Itoa(nat4Remaining),
|
Remaining: strconv.Itoa(nat4Remaining),
|
||||||
Total: strconv.Itoa(totalNAT4Ports),
|
Total: strconv.Itoa(totalNAT4Ports),
|
||||||
},
|
},
|
||||||
|
IPv4: routeCapacity{
|
||||||
|
Used: ipv4Used,
|
||||||
|
Remaining: strconv.Itoa(ipv4Remaining),
|
||||||
|
Total: strconv.Itoa(ipv4Total),
|
||||||
|
},
|
||||||
IPv6: routeCapacity{
|
IPv6: routeCapacity{
|
||||||
Used: len(ipv6Assignments),
|
Used: len(ipv6Assignments),
|
||||||
Remaining: ipv6Remaining,
|
Remaining: ipv6Remaining,
|
||||||
Total: ipv6Total,
|
Total: ipv6Total,
|
||||||
},
|
},
|
||||||
NAT4Mappings: nat4Mappings,
|
HostPublicIPv4: hostPublicIPv4,
|
||||||
IPv6Assignments: ipv6Assignments,
|
PublicIPv4Addresses: publicIPv4s,
|
||||||
IPv6Prefixes: prefixes,
|
IPv4Assignments: ipv4Assignments,
|
||||||
|
NAT4Mappings: nat4Mappings,
|
||||||
|
IPv6Assignments: ipv6Assignments,
|
||||||
|
IPv6Prefixes: prefixes,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func handleRoutingPoolsUpdate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !requireScope(w, r, "routing:write") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req routingPoolsRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Items != nil || req.Addresses != nil {
|
||||||
|
items := []config.PublicIPv4Assignment{}
|
||||||
|
if req.Items != nil {
|
||||||
|
items = *req.Items
|
||||||
|
} else if req.Addresses != nil {
|
||||||
|
items = make([]config.PublicIPv4Assignment, 0, len(*req.Addresses))
|
||||||
|
for _, address := range *req.Addresses {
|
||||||
|
items = append(items, config.PublicIPv4Assignment{Address: address})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
normalized, err := lxc.NormalizePublicIPv4Pool(items)
|
||||||
|
if err != nil {
|
||||||
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
allowed := map[string]bool{}
|
||||||
|
for _, item := range normalized {
|
||||||
|
allowed[item.Address] = true
|
||||||
|
}
|
||||||
|
for _, c := range config.AppConfig.Containers {
|
||||||
|
for _, item := range c.PublicIPv4s {
|
||||||
|
if item.Address != "" && !allowed[item.Address] {
|
||||||
|
jsonResponse(w, http.StatusBadRequest, APIResponse{
|
||||||
|
Success: false,
|
||||||
|
Message: "IPv4 " + item.Address + " is assigned to container " + c.Name + " and cannot be removed from the pool",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
config.AppConfig.PublicIPv4Pool = normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.IPv6Prefixes != nil {
|
||||||
|
normalized, err := lxc.NormalizePublicIPv6Prefixes(*req.IPv6Prefixes)
|
||||||
|
if err != nil {
|
||||||
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
parsedPrefixes := make([]netip.Prefix, 0, len(normalized))
|
||||||
|
for _, item := range normalized {
|
||||||
|
prefix, err := netip.ParsePrefix(item.Prefix)
|
||||||
|
if err == nil {
|
||||||
|
parsedPrefixes = append(parsedPrefixes, prefix)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, c := range config.AppConfig.Containers {
|
||||||
|
c.NormalizeNetworkAssignments()
|
||||||
|
for _, item := range c.IPv6Addresses {
|
||||||
|
if item.Address == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
addr, err := netip.ParseAddr(item.Address)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
contained := false
|
||||||
|
for _, prefix := range parsedPrefixes {
|
||||||
|
if prefix.Contains(addr) {
|
||||||
|
contained = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !contained {
|
||||||
|
jsonResponse(w, http.StatusBadRequest, APIResponse{
|
||||||
|
Success: false,
|
||||||
|
Message: "IPv6 " + item.Address + " is assigned to container " + c.Name + " and cannot be removed from the pool",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
config.AppConfig.PublicIPv6Prefixes = normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := config.SaveConfig(); err != nil {
|
||||||
|
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to save configuration"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
handleRoutingGet(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func totalIPv6Capacity(prefixes []lxc.IPv6PrefixInfo) string {
|
||||||
|
if len(prefixes) == 0 {
|
||||||
|
return "0"
|
||||||
|
}
|
||||||
|
var total uint64
|
||||||
|
for _, prefix := range prefixes {
|
||||||
|
capacity := lxc.IPv6PrefixCapacity(prefix.PrefixLen)
|
||||||
|
if capacity == "large" {
|
||||||
|
return "large"
|
||||||
|
}
|
||||||
|
parsed, err := strconv.ParseUint(capacity, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ^uint64(0)-total < parsed {
|
||||||
|
return "large"
|
||||||
|
}
|
||||||
|
total += parsed
|
||||||
|
}
|
||||||
|
if total == 0 {
|
||||||
|
return "0"
|
||||||
|
}
|
||||||
|
return strconv.FormatUint(total, 10)
|
||||||
|
}
|
||||||
|
|
||||||
func subtractCapacity(total string, used int) string {
|
func subtractCapacity(total string, used int) string {
|
||||||
if total == "" || total == "0" {
|
if total == "" || total == "0" {
|
||||||
return "0"
|
return "0"
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"clicd/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHandleRoutingGetAllowsRoutingWriteScope(t *testing.T) {
|
||||||
|
config.AppConfig = &config.ClicdConfig{}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/routing", nil)
|
||||||
|
req = withAuthContext(req, AuthContext{
|
||||||
|
Type: authTypeAPIKey,
|
||||||
|
Scopes: []string{"routing:write"},
|
||||||
|
})
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handleRoutingGet(rec, req)
|
||||||
|
|
||||||
|
if rec.Code == http.StatusForbidden {
|
||||||
|
t.Fatal("routing:write scope should be able to receive the routing response after updates")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,10 +13,16 @@ import (
|
|||||||
|
|
||||||
var kvmManager = kvm.NewManager()
|
var kvmManager = kvm.NewManager()
|
||||||
|
|
||||||
|
const noNetworkSelectedMessage = "请勾选任意一个可用网络"
|
||||||
|
|
||||||
func runtimeFromRequest(value string) string {
|
func runtimeFromRequest(value string) string {
|
||||||
return config.NormalizeVirtualization(value)
|
return config.NormalizeVirtualization(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func hasRequestedNetwork(cfg lxc.ContainerConfig) bool {
|
||||||
|
return cfg.WantsNAT() || cfg.AssignIPv4 || len(cfg.PublicIPv4s) > 0 || cfg.AssignIPv6 || len(cfg.IPv6Addresses) > 0
|
||||||
|
}
|
||||||
|
|
||||||
func runtimeFromTemplateID(templateID string) string {
|
func runtimeFromTemplateID(templateID string) string {
|
||||||
if kvm.FindImage(templateID) != nil {
|
if kvm.FindImage(templateID) != nil {
|
||||||
return config.VirtualizationKVM
|
return config.VirtualizationKVM
|
||||||
@@ -32,6 +38,26 @@ func createByRuntime(cfg lxc.ContainerConfig) error {
|
|||||||
return lxcManager.CreateContainer(cfg)
|
return lxcManager.CreateContainer(cfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func validateCreateSSHAuth(cfg lxc.ContainerConfig) error {
|
||||||
|
if cfg.Virtualization == config.VirtualizationKVM && kvm.IsWindowsImage(cfg.TemplateID) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_, err := lxc.ResolveCreateSSHAccess(cfg)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateReinstallSSHAuth(c *config.Container, templateID string, cfg lxc.ContainerConfig) error {
|
||||||
|
if c != nil && c.IsKVM() && kvm.IsWindowsImage(templateID) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
currentPassword := ""
|
||||||
|
if c != nil {
|
||||||
|
currentPassword = c.SSHPassword
|
||||||
|
}
|
||||||
|
_, err := lxc.ResolveReinstallSSHAccess(currentPassword, cfg)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
func startByRuntime(id int) error {
|
func startByRuntime(id int) error {
|
||||||
c := config.FindContainer(id)
|
c := config.FindContainer(id)
|
||||||
if c != nil && c.IsKVM() {
|
if c != nil && c.IsKVM() {
|
||||||
@@ -64,12 +90,12 @@ func destroyByRuntime(id int) error {
|
|||||||
return lxcManager.DestroyContainer(id)
|
return lxcManager.DestroyContainer(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
func reinstallByRuntime(id int, templateID string) error {
|
func reinstallByRuntime(id int, templateID string, authConfig ...lxc.ContainerConfig) error {
|
||||||
c := config.FindContainer(id)
|
c := config.FindContainer(id)
|
||||||
if c != nil && c.IsKVM() {
|
if c != nil && c.IsKVM() {
|
||||||
return kvmManager.ReinstallContainer(id, templateID)
|
return kvmManager.ReinstallContainer(id, templateID, authConfig...)
|
||||||
}
|
}
|
||||||
return lxcManager.ReinstallContainer(id, templateID)
|
return lxcManager.ReinstallContainer(id, templateID, authConfig...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func resetPasswordByRuntime(id int, password string) (string, error) {
|
func resetPasswordByRuntime(id int, password string) (string, error) {
|
||||||
|
|||||||
@@ -654,18 +654,27 @@ func HandleSecurityAlerts(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||||
return
|
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.
|
// HandleSecuritySettings returns or updates security automation settings.
|
||||||
func HandleSecuritySettings(w http.ResponseWriter, r *http.Request) {
|
func HandleSecuritySettings(w http.ResponseWriter, r *http.Request) {
|
||||||
switch r.Method {
|
switch r.Method {
|
||||||
case http.MethodGet:
|
case http.MethodGet:
|
||||||
|
if !requireScope(w, r, "security:read") {
|
||||||
|
return
|
||||||
|
}
|
||||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]bool{
|
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]bool{
|
||||||
"auto_shutdown": config.AppConfig.SecurityAutoShutdown,
|
"auto_shutdown": config.AppConfig.SecurityAutoShutdown,
|
||||||
}})
|
}})
|
||||||
case http.MethodPut:
|
case http.MethodPut:
|
||||||
|
if !requireScope(w, r, "security:settings") {
|
||||||
|
return
|
||||||
|
}
|
||||||
var req struct {
|
var req struct {
|
||||||
AutoShutdown bool `json:"auto_shutdown"`
|
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()})
|
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
|
||||||
return
|
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{
|
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]bool{
|
||||||
"auto_shutdown": config.AppConfig.SecurityAutoShutdown,
|
"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"})
|
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requireScope(w, r, "security:check") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var req struct {
|
var req struct {
|
||||||
ContainerName string `json:"container_name"`
|
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"})
|
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found or not running"})
|
||||||
return
|
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)
|
ensureScanner().checkContainer(c.Name, c.IP)
|
||||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Security check completed"})
|
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"})
|
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requireScope(w, r, "security:read") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
containerName := r.URL.Query().Get("container")
|
containerName := r.URL.Query().Get("container")
|
||||||
if containerName == "" {
|
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{}{}})
|
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: []map[string]interface{}{}})
|
||||||
return
|
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)})
|
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"})
|
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requireScope(w, r, "security:read") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
critical := 0
|
critical := 0
|
||||||
high := 0
|
high := 0
|
||||||
medium := 0
|
medium := 0
|
||||||
low := 0
|
low := 0
|
||||||
alerts := mergedSecurityAlerts()
|
alerts := filterSecurityAlertsForRequest(r, mergedSecurityAlerts())
|
||||||
for _, a := range alerts {
|
for _, a := range alerts {
|
||||||
switch a.Severity {
|
switch a.Severity {
|
||||||
case "critical":
|
case "critical":
|
||||||
@@ -812,6 +839,20 @@ func HandleContainerSecuritySummary(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: summary})
|
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 {
|
func mergedSecurityAlerts() []SecurityAlert {
|
||||||
ss := ensureScanner()
|
ss := ensureScanner()
|
||||||
ss.mu.Lock()
|
ss.mu.Lock()
|
||||||
|
|||||||
@@ -20,6 +20,34 @@ type LoginLog struct {
|
|||||||
|
|
||||||
var loginLogs = make([]LoginLog, 0)
|
var loginLogs = make([]LoginLog, 0)
|
||||||
|
|
||||||
|
// HandleLanguage returns or updates the global panel language.
|
||||||
|
func HandleLanguage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]string{
|
||||||
|
"language": config.NormalizeLanguage(config.AppConfig.Language),
|
||||||
|
}})
|
||||||
|
case http.MethodPost, http.MethodPut:
|
||||||
|
var req struct {
|
||||||
|
Language string `json:"language"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
config.AppConfig.Language = config.NormalizeLanguage(req.Language)
|
||||||
|
if err := config.SaveConfig(); err != nil {
|
||||||
|
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to save language"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]string{
|
||||||
|
"language": config.AppConfig.Language,
|
||||||
|
}})
|
||||||
|
default:
|
||||||
|
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// RecordLoginLog adds a login attempt to the log (persisted to config)
|
// RecordLoginLog adds a login attempt to the log (persisted to config)
|
||||||
func RecordLoginLog(username, ip, userAgent string, success bool) {
|
func RecordLoginLog(username, ip, userAgent string, success bool) {
|
||||||
config.AddLoginLog(username, ip, userAgent, success)
|
config.AddLoginLog(username, ip, userAgent, success)
|
||||||
@@ -56,6 +84,9 @@ func HandleLoginLogs(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requireScope(w, r, "loginlog:read") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Return in reverse (newest first)
|
// Return in reverse (newest first)
|
||||||
reversed := make([]LoginLog, len(loginLogs))
|
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"})
|
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requireScope(w, r, "snapshot:read") {
|
||||||
|
return
|
||||||
|
}
|
||||||
snapshots := append([]config.Snapshot(nil), config.AppConfig.Snapshots...)
|
snapshots := append([]config.Snapshot(nil), config.AppConfig.Snapshots...)
|
||||||
|
snapshots = filterSnapshotsForRequest(r, snapshots)
|
||||||
sortSnapshotsNewestFirst(snapshots)
|
sortSnapshotsNewestFirst(snapshots)
|
||||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: 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) {
|
func handleContainerSnapshots(w http.ResponseWriter, r *http.Request, containerID int, action string) {
|
||||||
switch {
|
switch {
|
||||||
case action == "snapshots" && r.Method == http.MethodGet:
|
case action == "snapshots" && r.Method == http.MethodGet:
|
||||||
|
if !requireScope(w, r, "snapshot:read") {
|
||||||
|
return
|
||||||
|
}
|
||||||
listContainerSnapshots(w, r, containerID)
|
listContainerSnapshots(w, r, containerID)
|
||||||
case action == "snapshots" && r.Method == http.MethodPost:
|
case action == "snapshots" && r.Method == http.MethodPost:
|
||||||
|
if !requireScope(w, r, "snapshot:create") {
|
||||||
|
return
|
||||||
|
}
|
||||||
createContainerSnapshot(w, r, containerID)
|
createContainerSnapshot(w, r, containerID)
|
||||||
case action == "snapshots/schedule" && r.Method == http.MethodPost:
|
case action == "snapshots/schedule" && r.Method == http.MethodPost:
|
||||||
|
if !requireScope(w, r, "snapshot:schedule") {
|
||||||
|
return
|
||||||
|
}
|
||||||
updateSnapshotSchedule(w, r, containerID)
|
updateSnapshotSchedule(w, r, containerID)
|
||||||
case action == "snapshots/quota" && r.Method == http.MethodPut:
|
case action == "snapshots/quota" && r.Method == http.MethodPut:
|
||||||
|
if !requireScope(w, r, "snapshot:schedule") {
|
||||||
|
return
|
||||||
|
}
|
||||||
updateSnapshotQuota(w, r, containerID)
|
updateSnapshotQuota(w, r, containerID)
|
||||||
case strings.HasPrefix(action, "snapshots/") && strings.HasSuffix(action, "/restore") && r.Method == http.MethodPost:
|
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")
|
snapshotID := strings.TrimSuffix(strings.TrimPrefix(action, "snapshots/"), "/restore")
|
||||||
restoreContainerSnapshot(w, r, containerID, snapshotID)
|
restoreContainerSnapshot(w, r, containerID, snapshotID)
|
||||||
case strings.HasPrefix(action, "snapshots/") && r.Method == http.MethodDelete:
|
case strings.HasPrefix(action, "snapshots/") && r.Method == http.MethodDelete:
|
||||||
|
if !requireScope(w, r, "snapshot:delete") {
|
||||||
|
return
|
||||||
|
}
|
||||||
snapshotID := strings.TrimPrefix(action, "snapshots/")
|
snapshotID := strings.TrimPrefix(action, "snapshots/")
|
||||||
deleteContainerSnapshot(w, r, containerID, snapshotID)
|
deleteContainerSnapshot(w, r, containerID, snapshotID)
|
||||||
default:
|
default:
|
||||||
@@ -186,15 +208,7 @@ func restoreContainerSnapshot(w http.ResponseWriter, r *http.Request, containerI
|
|||||||
}
|
}
|
||||||
|
|
||||||
func requestUser(r *http.Request) string {
|
func requestUser(r *http.Request) string {
|
||||||
if claims, ok := claimsFromRequest(r); ok {
|
return requestActor(r)
|
||||||
if subUser, _ := claims["sub_user"].(string); subUser != "" {
|
|
||||||
return "user:" + subUser
|
|
||||||
}
|
|
||||||
if username, _ := claims["username"].(string); username != "" {
|
|
||||||
return username
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "admin"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func sortSnapshotsNewestFirst(snapshots []config.Snapshot) {
|
func sortSnapshotsNewestFirst(snapshots []config.Snapshot) {
|
||||||
@@ -204,3 +218,17 @@ func sortSnapshotsNewestFirst(snapshots []config.Snapshot) {
|
|||||||
return tj.Before(ti)
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !requireScope(w, r, "terminal:ssh") {
|
||||||
|
return
|
||||||
|
}
|
||||||
var req struct {
|
var req struct {
|
||||||
ContainerName string `json:"container_name"`
|
ContainerName string `json:"container_name"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,587 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/ecdsa"
|
||||||
|
"crypto/elliptic"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
|
"crypto/x509/pkix"
|
||||||
|
"encoding/json"
|
||||||
|
"encoding/pem"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"math/big"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"clicd/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
type sslSettingsRequest struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
Target string `json:"target"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
CertPEM string `json:"cert_pem"`
|
||||||
|
KeyPEM string `json:"key_pem"`
|
||||||
|
ApplyNow bool `json:"apply_now"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type sslCertificateInfo struct {
|
||||||
|
Subject string `json:"subject"`
|
||||||
|
Issuer string `json:"issuer"`
|
||||||
|
DNSNames []string `json:"dns_names"`
|
||||||
|
IPNames []string `json:"ip_names"`
|
||||||
|
NotBefore string `json:"not_before"`
|
||||||
|
NotAfter string `json:"not_after"`
|
||||||
|
Valid bool `json:"valid"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type sslSavedCertificateStatus struct {
|
||||||
|
config.SSLConfig
|
||||||
|
Certificate *sslCertificateInfo `json:"certificate,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type sslSettingsResponse struct {
|
||||||
|
config.SSLConfig
|
||||||
|
DetectedHost string `json:"detected_host"`
|
||||||
|
Certificate *sslCertificateInfo `json:"certificate,omitempty"`
|
||||||
|
ModeCertificates map[string]sslSavedCertificateStatus `json:"mode_certificates"`
|
||||||
|
NeedsRestart bool `json:"needs_restart,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func HandleSSLSettings(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: sslSettingsStatus(r, false)})
|
||||||
|
case http.MethodPut:
|
||||||
|
updateSSLSettings(w, r)
|
||||||
|
default:
|
||||||
|
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateSSLSettings(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req sslSettingsRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
mode := config.NormalizeSSLMode(req.Mode)
|
||||||
|
if !req.Enabled || mode == config.SSLModeDisabled {
|
||||||
|
saveCurrentSSLSlot()
|
||||||
|
config.AppConfig.SSL = config.SSLConfig{Enabled: false, Mode: config.SSLModeDisabled}
|
||||||
|
if err := config.SaveConfig(); err != nil {
|
||||||
|
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Save SSL settings failed"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
restartIfRequested(req.ApplyNow)
|
||||||
|
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "SSL disabled", Data: sslSettingsStatus(r, true)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
target := strings.TrimSpace(req.Target)
|
||||||
|
if target == "" {
|
||||||
|
target = detectedRequestHost(r)
|
||||||
|
}
|
||||||
|
normalizedTarget, err := config.NormalizeSSLCertificateTarget(target)
|
||||||
|
if err != nil {
|
||||||
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
target = normalizedTarget
|
||||||
|
|
||||||
|
next, err := resolveSSLModeCertificate(mode, target, strings.TrimSpace(req.Email), req.CertPEM, req.KeyPEM)
|
||||||
|
if err != nil {
|
||||||
|
_ = config.SaveConfig()
|
||||||
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error(), Data: sslSettingsStatus(r, false)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := validateCertificatePair(next.CertPath, next.KeyPath); err != nil {
|
||||||
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.LastIssuedAt = time.Now().Format(time.RFC3339)
|
||||||
|
next.Enabled = true
|
||||||
|
config.AppConfig.SSL = next
|
||||||
|
saveSSLSlot(next)
|
||||||
|
if err := config.SaveConfig(); err != nil {
|
||||||
|
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Save SSL settings failed"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
restartIfRequested(req.ApplyNow)
|
||||||
|
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "SSL settings saved", Data: sslSettingsStatus(r, true)})
|
||||||
|
}
|
||||||
|
|
||||||
|
func sslSettingsStatus(r *http.Request, needsRestart bool) sslSettingsResponse {
|
||||||
|
cfg := config.AppConfig.SSL
|
||||||
|
cfg.KeyPath = maskExistingPath(cfg.KeyPath)
|
||||||
|
resp := sslSettingsResponse{
|
||||||
|
SSLConfig: cfg,
|
||||||
|
DetectedHost: detectedRequestHost(r),
|
||||||
|
ModeCertificates: sslModeCertificatesStatus(),
|
||||||
|
NeedsRestart: needsRestart,
|
||||||
|
}
|
||||||
|
if cert, err := readCertificateInfo(config.AppConfig.SSL.CertPath); err == nil {
|
||||||
|
resp.Certificate = cert
|
||||||
|
}
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveSSLModeCertificate(mode, target, email, certPEM, keyPEM string) (config.SSLConfig, error) {
|
||||||
|
if config.AppConfig.SSLCertificates == nil {
|
||||||
|
config.AppConfig.SSLCertificates = map[string]config.SSLConfig{}
|
||||||
|
}
|
||||||
|
next := config.AppConfig.SSLCertificates[mode]
|
||||||
|
next.Mode = mode
|
||||||
|
next.Target = target
|
||||||
|
if email != "" || next.Email == "" {
|
||||||
|
next.Email = email
|
||||||
|
}
|
||||||
|
|
||||||
|
var err error
|
||||||
|
switch mode {
|
||||||
|
case config.SSLModeUploaded:
|
||||||
|
if strings.TrimSpace(certPEM) != "" || strings.TrimSpace(keyPEM) != "" {
|
||||||
|
next.CertPath, next.KeyPath, err = saveUploadedCertificate(certPEM, keyPEM)
|
||||||
|
} else if next.CertPath == "" || next.KeyPath == "" {
|
||||||
|
err = fmt.Errorf("certificate and private key are required")
|
||||||
|
} else if !certificateUsable(next.CertPath, next.KeyPath, target) {
|
||||||
|
err = fmt.Errorf("uploaded certificate is expired, invalid, or does not match the target")
|
||||||
|
}
|
||||||
|
case config.SSLModeSelfSigned:
|
||||||
|
if !certificateUsable(next.CertPath, next.KeyPath, target) {
|
||||||
|
next.CertPath, next.KeyPath, err = generateSelfSignedCertificate(target)
|
||||||
|
}
|
||||||
|
case config.SSLModeLetsEncrypt:
|
||||||
|
if !certificateUsable(next.CertPath, next.KeyPath, target) {
|
||||||
|
next.CertPath, next.KeyPath, err = requestLetsEncryptCertificate(target, next.Email)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
err = fmt.Errorf("unsupported SSL mode")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
next.LastError = err.Error()
|
||||||
|
saveSSLSlot(next)
|
||||||
|
return next, err
|
||||||
|
}
|
||||||
|
next.LastError = ""
|
||||||
|
return next, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sslModeCertificatesStatus() map[string]sslSavedCertificateStatus {
|
||||||
|
result := map[string]sslSavedCertificateStatus{}
|
||||||
|
for _, mode := range []string{config.SSLModeLetsEncrypt, config.SSLModeSelfSigned, config.SSLModeUploaded} {
|
||||||
|
cfg := config.AppConfig.SSLCertificates[mode]
|
||||||
|
cfg.KeyPath = maskExistingPath(cfg.KeyPath)
|
||||||
|
status := sslSavedCertificateStatus{SSLConfig: cfg}
|
||||||
|
if cert, err := readCertificateInfo(config.AppConfig.SSLCertificates[mode].CertPath); err == nil {
|
||||||
|
status.Certificate = cert
|
||||||
|
}
|
||||||
|
result[mode] = status
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveCurrentSSLSlot() {
|
||||||
|
if config.AppConfig.SSL.Mode == config.SSLModeDisabled || config.AppConfig.SSL.CertPath == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
saveSSLSlot(config.AppConfig.SSL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveSSLSlot(ssl config.SSLConfig) {
|
||||||
|
mode := config.NormalizeSSLMode(ssl.Mode)
|
||||||
|
if mode == config.SSLModeDisabled {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if config.AppConfig.SSLCertificates == nil {
|
||||||
|
config.AppConfig.SSLCertificates = map[string]config.SSLConfig{}
|
||||||
|
}
|
||||||
|
ssl.Mode = mode
|
||||||
|
ssl.Enabled = false
|
||||||
|
config.AppConfig.SSLCertificates[mode] = ssl
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveUploadedCertificate(certPEM, keyPEM string) (string, string, error) {
|
||||||
|
certPEM = strings.TrimSpace(certPEM)
|
||||||
|
keyPEM = strings.TrimSpace(keyPEM)
|
||||||
|
if certPEM == "" || keyPEM == "" {
|
||||||
|
return "", "", fmt.Errorf("certificate and private key are required")
|
||||||
|
}
|
||||||
|
if _, err := tls.X509KeyPair([]byte(certPEM), []byte(keyPEM)); err != nil {
|
||||||
|
return "", "", fmt.Errorf("certificate/private key mismatch: %v", err)
|
||||||
|
}
|
||||||
|
certPath, keyPath, err := config.UploadedSSLPaths()
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(certPath, []byte(certPEM+"\n"), 0600); err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(keyPath, []byte(keyPEM+"\n"), 0600); err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
return certPath, keyPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateSelfSignedCertificate(target string) (string, string, error) {
|
||||||
|
target = strings.TrimSpace(target)
|
||||||
|
normalizedTarget, err := config.NormalizeSSLCertificateTarget(target)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
target = normalizedTarget
|
||||||
|
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
tpl := x509.Certificate{
|
||||||
|
SerialNumber: serial,
|
||||||
|
Subject: pkix.Name{
|
||||||
|
CommonName: target,
|
||||||
|
},
|
||||||
|
NotBefore: now.Add(-time.Hour),
|
||||||
|
NotAfter: now.AddDate(1, 0, 0),
|
||||||
|
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
|
||||||
|
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||||
|
BasicConstraintsValid: true,
|
||||||
|
}
|
||||||
|
if ip := net.ParseIP(target); ip != nil {
|
||||||
|
tpl.IPAddresses = []net.IP{ip}
|
||||||
|
} else {
|
||||||
|
tpl.DNSNames = []string{target}
|
||||||
|
}
|
||||||
|
der, err := x509.CreateCertificate(rand.Reader, &tpl, &tpl, &key.PublicKey, key)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
keyDER, err := x509.MarshalECPrivateKey(key)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
certPath, keyPath, err := config.SelfSignedSSLPaths()
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
certOut := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
|
||||||
|
keyOut := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
|
||||||
|
if err := os.WriteFile(certPath, certOut, 0600); err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(keyPath, keyOut, 0600); err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
return certPath, keyPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestLetsEncryptCertificate(target, email string) (string, string, error) {
|
||||||
|
if _, err := exec.LookPath("certbot"); err != nil {
|
||||||
|
return "", "", fmt.Errorf("certbot is not installed on this server")
|
||||||
|
}
|
||||||
|
target = strings.TrimSpace(target)
|
||||||
|
normalizedTarget, err := config.NormalizeSSLCertificateTarget(target)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
target = normalizedTarget
|
||||||
|
args := []string{"certonly", "--non-interactive", "--agree-tos", "--standalone"}
|
||||||
|
if email != "" {
|
||||||
|
args = append(args, "--email", email)
|
||||||
|
} else {
|
||||||
|
args = append(args, "--register-unsafely-without-email")
|
||||||
|
}
|
||||||
|
if net.ParseIP(target) != nil {
|
||||||
|
if err := ensureCertbotSupportsIPCertificates(); err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
args = append(args, "--preferred-profile", "shortlived", "--ip-address", target)
|
||||||
|
} else {
|
||||||
|
args = append(args, "-d", target)
|
||||||
|
}
|
||||||
|
cmd := exec.Command("certbot", args...)
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("Let's Encrypt request failed: %s", strings.TrimSpace(string(output)))
|
||||||
|
}
|
||||||
|
certPath, keyPath, err := config.LetsEncryptSSLPaths(target)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
if _, err := config.ReadableFileStat(certPath); err != nil {
|
||||||
|
return "", "", fmt.Errorf("Let's Encrypt certificate file not found after issuance: %s", certPath)
|
||||||
|
}
|
||||||
|
if _, err := config.ReadableFileStat(keyPath); err != nil {
|
||||||
|
return "", "", fmt.Errorf("Let's Encrypt private key file not found after issuance: %s", keyPath)
|
||||||
|
}
|
||||||
|
return certPath, keyPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureCertbotSupportsIPCertificates() error {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
cmd := exec.CommandContext(ctx, "certbot", "--help", "all")
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if ctx.Err() == context.DeadlineExceeded {
|
||||||
|
return fmt.Errorf("certbot check timed out")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("certbot capability check failed: %s", strings.TrimSpace(string(output)))
|
||||||
|
}
|
||||||
|
help := string(output)
|
||||||
|
if !strings.Contains(help, "--ip-address") || !strings.Contains(help, "--preferred-profile") {
|
||||||
|
return fmt.Errorf("current certbot does not support IP certificates; install Certbot 5.4+ from snap or another current source")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateCertificatePair(certPath, keyPath string) error {
|
||||||
|
safeCertPath, err := config.ResolveSSLPath(certPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
safeKeyPath, err := config.ResolveSSLPath(keyPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
certPEM, err := os.ReadFile(safeCertPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
keyPEM, err := os.ReadFile(safeKeyPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := tls.X509KeyPair(certPEM, keyPEM); err != nil {
|
||||||
|
return fmt.Errorf("certificate/private key mismatch: %v", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func certificateUsable(certPath, keyPath, target string) bool {
|
||||||
|
if certPath == "" || keyPath == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if err := validateCertificatePair(certPath, keyPath); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
cert, err := readLeafCertificate(certPath)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
if now.Before(cert.NotBefore) || !now.Before(cert.NotAfter) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return certificateMatchesTarget(cert, target)
|
||||||
|
}
|
||||||
|
|
||||||
|
func certificateNeedsRenewal(certPath, keyPath, target string, renewBefore time.Duration) bool {
|
||||||
|
if certPath == "" || keyPath == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if err := validateCertificatePair(certPath, keyPath); err != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
cert, err := readLeafCertificate(certPath)
|
||||||
|
if err != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
if now.Before(cert.NotBefore) || !now.Before(cert.NotAfter) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if !certificateMatchesTarget(cert, target) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return cert.NotAfter.Sub(now) <= renewBefore
|
||||||
|
}
|
||||||
|
|
||||||
|
func certificateMatchesTarget(cert *x509.Certificate, target string) bool {
|
||||||
|
target = strings.TrimSpace(strings.Trim(target, "[]"))
|
||||||
|
if target == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if ip := net.ParseIP(target); ip != nil {
|
||||||
|
for _, certIP := range cert.IPAddresses {
|
||||||
|
if certIP.Equal(ip) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if err := cert.VerifyHostname(target); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func readCertificateInfo(certPath string) (*sslCertificateInfo, error) {
|
||||||
|
cert, err := readLeafCertificate(certPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ipNames := make([]string, 0, len(cert.IPAddresses))
|
||||||
|
for _, ip := range cert.IPAddresses {
|
||||||
|
ipNames = append(ipNames, ip.String())
|
||||||
|
}
|
||||||
|
return &sslCertificateInfo{
|
||||||
|
Subject: cert.Subject.String(),
|
||||||
|
Issuer: cert.Issuer.String(),
|
||||||
|
DNSNames: cert.DNSNames,
|
||||||
|
IPNames: ipNames,
|
||||||
|
NotBefore: cert.NotBefore.Format(time.RFC3339),
|
||||||
|
NotAfter: cert.NotAfter.Format(time.RFC3339),
|
||||||
|
Valid: time.Now().After(cert.NotBefore) && time.Now().Before(cert.NotAfter),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readLeafCertificate(certPath string) (*x509.Certificate, error) {
|
||||||
|
if certPath == "" {
|
||||||
|
return nil, errors.New("certificate path is empty")
|
||||||
|
}
|
||||||
|
safeCertPath, err := config.ResolveSSLPath(certPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(safeCertPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
block, _ := pem.Decode(data)
|
||||||
|
if block == nil {
|
||||||
|
return nil, errors.New("certificate PEM is invalid")
|
||||||
|
}
|
||||||
|
cert, err := x509.ParseCertificate(block.Bytes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return cert, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func detectedRequestHost(r *http.Request) string {
|
||||||
|
host := strings.TrimSpace(r.Host)
|
||||||
|
if host == "" {
|
||||||
|
return firstPublicInterfaceIP()
|
||||||
|
}
|
||||||
|
if h, _, err := net.SplitHostPort(host); err == nil {
|
||||||
|
host = h
|
||||||
|
}
|
||||||
|
host = strings.Trim(host, "[]")
|
||||||
|
if host == "localhost" || net.ParseIP(host).IsLoopback() {
|
||||||
|
if ip := firstPublicInterfaceIP(); ip != "" {
|
||||||
|
return ip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstPublicInterfaceIP() string {
|
||||||
|
addrs, err := net.InterfaceAddrs()
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for _, addr := range addrs {
|
||||||
|
ipNet, ok := addr.(*net.IPNet)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ip := ipNet.IP.To4()
|
||||||
|
if ip == nil || ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return ip.String()
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func maskExistingPath(path string) string {
|
||||||
|
if path == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
func restartIfRequested(applyNow bool) {
|
||||||
|
if !applyNow {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
_ = exec.Command("systemctl", "restart", "clicd").Start()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func StartSSLRenewalMonitor() {
|
||||||
|
go func() {
|
||||||
|
time.Sleep(30 * time.Second)
|
||||||
|
renewSavedSSLCertificates()
|
||||||
|
ticker := time.NewTicker(6 * time.Hour)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for range ticker.C {
|
||||||
|
renewSavedSSLCertificates()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func renewSavedSSLCertificates() {
|
||||||
|
if config.AppConfig == nil || len(config.AppConfig.SSLCertificates) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
changed := false
|
||||||
|
for mode, cert := range config.AppConfig.SSLCertificates {
|
||||||
|
mode = config.NormalizeSSLMode(mode)
|
||||||
|
if cert.Target == "" || mode == config.SSLModeDisabled || mode == config.SSLModeUploaded {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var certPath, keyPath string
|
||||||
|
var err error
|
||||||
|
switch mode {
|
||||||
|
case config.SSLModeLetsEncrypt:
|
||||||
|
if !certificateNeedsRenewal(cert.CertPath, cert.KeyPath, cert.Target, 48*time.Hour) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
certPath, keyPath, err = requestLetsEncryptCertificate(cert.Target, cert.Email)
|
||||||
|
case config.SSLModeSelfSigned:
|
||||||
|
if !certificateNeedsRenewal(cert.CertPath, cert.KeyPath, cert.Target, 30*24*time.Hour) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
certPath, keyPath, err = generateSelfSignedCertificate(cert.Target)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
cert.LastError = err.Error()
|
||||||
|
config.AppConfig.SSLCertificates[mode] = cert
|
||||||
|
changed = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cert.CertPath = certPath
|
||||||
|
cert.KeyPath = keyPath
|
||||||
|
cert.LastIssuedAt = time.Now().Format(time.RFC3339)
|
||||||
|
cert.LastError = ""
|
||||||
|
config.AppConfig.SSLCertificates[mode] = cert
|
||||||
|
if config.AppConfig.SSL.Enabled && config.AppConfig.SSL.Mode == mode {
|
||||||
|
active := cert
|
||||||
|
active.Enabled = true
|
||||||
|
config.AppConfig.SSL = active
|
||||||
|
}
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if changed {
|
||||||
|
_ = config.SaveConfig()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -49,6 +49,9 @@ func HandleSubUserCreate(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requireScope(w, r, "subuser:create") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var req struct {
|
var req struct {
|
||||||
ContainerName string `json:"container_name"`
|
ContainerName string `json:"container_name"`
|
||||||
@@ -281,13 +284,40 @@ func subUserAllowedContainers(r *http.Request) (subUserAccess, bool) {
|
|||||||
return allowed, true
|
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 {
|
func containerByIdentifier(identifier string) *config.Container {
|
||||||
return config.FindContainerByIdentifier(identifier)
|
return config.FindContainerByIdentifier(identifier)
|
||||||
}
|
}
|
||||||
|
|
||||||
func isContainerAllowedForRequest(r *http.Request, identifier string) bool {
|
func isContainerAllowedForRequest(r *http.Request, identifier string) bool {
|
||||||
allowed, isSubUser := subUserAllowedContainers(r)
|
allowed, restricted := requestAllowedContainers(r)
|
||||||
if !isSubUser {
|
if !restricted {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
c := containerByIdentifier(identifier)
|
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"})
|
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requireScope(w, r, "audit:read") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
logs := config.AppConfig.AuditLogs
|
logs := config.AppConfig.AuditLogs
|
||||||
if logs == nil {
|
if logs == nil {
|
||||||
@@ -327,12 +360,20 @@ func SubUserMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
path := r.URL.Path
|
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)
|
next(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if path == "/api/containers" {
|
if path == containerListPath {
|
||||||
if r.Method != http.MethodGet {
|
if r.Method != http.MethodGet {
|
||||||
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Sub-users cannot create containers"})
|
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Sub-users cannot create containers"})
|
||||||
return
|
return
|
||||||
@@ -341,8 +382,8 @@ func SubUserMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(path) > len("/api/containers/") {
|
if strings.HasPrefix(path, containerPrefix) {
|
||||||
rest := path[len("/api/containers/"):]
|
rest := path[len(containerPrefix):]
|
||||||
parts := splitPath(rest)
|
parts := splitPath(rest)
|
||||||
if len(parts) > 0 && parts[0] != "" {
|
if len(parts) > 0 && parts[0] != "" {
|
||||||
c := containerByIdentifier(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 {
|
func filterContainersForRequest(r *http.Request, containers []config.Container) []config.Container {
|
||||||
allowed, isSubUser := subUserAllowedContainers(r)
|
allowed, restricted := requestAllowedContainers(r)
|
||||||
if !isSubUser {
|
if !restricted {
|
||||||
return containers
|
return containers
|
||||||
}
|
}
|
||||||
filtered := make([]config.Container, 0, len(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 {
|
func filterTasksForRequest(r *http.Request, tasks []*Task) []*Task {
|
||||||
allowed, isSubUser := subUserAllowedContainers(r)
|
|
||||||
if !isSubUser {
|
|
||||||
return tasks
|
|
||||||
}
|
|
||||||
filtered := make([]*Task, 0, len(tasks))
|
filtered := make([]*Task, 0, len(tasks))
|
||||||
for _, task := range tasks {
|
for _, task := range tasks {
|
||||||
if c := config.FindContainer(task.ContainerID); c != nil && isContainerAllowed(allowed, c) {
|
if isTaskAllowedForRequest(r, task) {
|
||||||
filtered = append(filtered, 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
|
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 {
|
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 {
|
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"})
|
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requireScope(w, r, "subuser:read") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
result := make([]SubUserListItem, 0, len(config.AppConfig.SubUsers))
|
result := make([]SubUserListItem, 0, len(config.AppConfig.SubUsers))
|
||||||
for _, su := range 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
|
// HandleSubUserAction handles actions on a specific sub-user
|
||||||
func HandleSubUserAction(w http.ResponseWriter, r *http.Request) {
|
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)
|
parts := strings.SplitN(path, "/", 2)
|
||||||
subUserID := parts[0]
|
subUserID := parts[0]
|
||||||
action := ""
|
action := ""
|
||||||
@@ -608,6 +667,9 @@ func HandleSubUserAction(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
switch {
|
switch {
|
||||||
case action == "rotate-password" && r.Method == http.MethodPost:
|
case action == "rotate-password" && r.Method == http.MethodPost:
|
||||||
|
if !requireScope(w, r, "subuser:update") {
|
||||||
|
return
|
||||||
|
}
|
||||||
password := generateRandomStr(16)
|
password := generateRandomStr(16)
|
||||||
if hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost); err == nil {
|
if hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost); err == nil {
|
||||||
target.PassHash = string(hash)
|
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"})
|
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to generate password"})
|
||||||
|
|
||||||
case action == "audit-logs" && r.Method == http.MethodGet:
|
case action == "audit-logs" && r.Method == http.MethodGet:
|
||||||
|
if !requireScope(w, r, "audit:read") {
|
||||||
|
return
|
||||||
|
}
|
||||||
// Filter audit logs for this sub-user
|
// Filter audit logs for this sub-user
|
||||||
logs := filterSubUserAuditLogs(target.Username)
|
logs := filterSubUserAuditLogs(target.Username)
|
||||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: logs})
|
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: logs})
|
||||||
|
|
||||||
case action == "login-logs" && r.Method == http.MethodGet:
|
case action == "login-logs" && r.Method == http.MethodGet:
|
||||||
|
if !requireScope(w, r, "loginlog:read") {
|
||||||
|
return
|
||||||
|
}
|
||||||
// Filter login logs for this sub-user
|
// Filter login logs for this sub-user
|
||||||
logs := filterSubUserLoginLogs(target.Username)
|
logs := filterSubUserLoginLogs(target.Username)
|
||||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: logs})
|
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: logs})
|
||||||
|
|||||||
@@ -11,19 +11,27 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type SwapInfo struct {
|
type SwapInfo struct {
|
||||||
TotalMB int64 `json:"total_mb"`
|
TotalMB int64 `json:"total_mb"`
|
||||||
UsedMB int64 `json:"used_mb"`
|
UsedMB int64 `json:"used_mb"`
|
||||||
FreeMB int64 `json:"free_mb"`
|
FreeMB int64 `json:"free_mb"`
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
SwapFile string `json:"swap_file"`
|
SwapFile string `json:"swap_file"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
minSwapSizeMB = 128
|
||||||
|
maxSwapSizeMB = 262144
|
||||||
|
)
|
||||||
|
|
||||||
// HandleSwapInfo returns current swap status
|
// HandleSwapInfo returns current swap status
|
||||||
func HandleSwapInfo(w http.ResponseWriter, r *http.Request) {
|
func HandleSwapInfo(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodGet {
|
if r.Method != http.MethodGet {
|
||||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requireScope(w, r, "swap:read") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
info := getSwapInfo()
|
info := getSwapInfo()
|
||||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: info})
|
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"})
|
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requireScope(w, r, "swap:manage") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var req struct {
|
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
|
SizeMB int `json:"size_mb"` // for create/resize
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
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 msg string
|
||||||
|
var err error
|
||||||
|
|
||||||
switch req.Action {
|
switch req.Action {
|
||||||
case "create":
|
case "create":
|
||||||
if req.SizeMB <= 0 {
|
if req.SizeMB <= 0 {
|
||||||
req.SizeMB = 2048
|
req.SizeMB = 2048
|
||||||
}
|
}
|
||||||
err := createSwap(req.SizeMB)
|
if err = validateSwapSize(req.SizeMB); err == nil {
|
||||||
if err != nil {
|
err = createSwap(req.SizeMB)
|
||||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
msg = fmt.Sprintf("已创建 %d MB SWAP", req.SizeMB)
|
msg = fmt.Sprintf("已创建 %d MB SWAP", req.SizeMB)
|
||||||
|
|
||||||
case "enable":
|
case "enable":
|
||||||
err := enableSwap()
|
err = enableSwap()
|
||||||
if err != nil {
|
|
||||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
msg = "SWAP 已启用"
|
msg = "SWAP 已启用"
|
||||||
|
|
||||||
case "disable":
|
case "disable":
|
||||||
err := disableSwap()
|
err = disableSwap()
|
||||||
if err != nil {
|
|
||||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
msg = "SWAP 已禁用"
|
msg = "SWAP 已禁用"
|
||||||
|
|
||||||
case "resize":
|
case "resize":
|
||||||
if req.SizeMB <= 0 {
|
if err = validateSwapSize(req.SizeMB); err == nil {
|
||||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid size"})
|
err = disableSwap()
|
||||||
return
|
}
|
||||||
|
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)
|
msg = fmt.Sprintf("SWAP 已调整为 %d MB", req.SizeMB)
|
||||||
|
|
||||||
default:
|
default:
|
||||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid action: " + req.Action})
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid action: " + req.Action})
|
||||||
return
|
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()
|
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})
|
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 {
|
func getSwapInfo() SwapInfo {
|
||||||
info := SwapInfo{SwapFile: "/swapfile"}
|
info := SwapInfo{SwapFile: "/swapfile"}
|
||||||
|
|
||||||
@@ -160,6 +180,9 @@ func createSwap(sizeMB int) error {
|
|||||||
func enableSwap() error {
|
func enableSwap() error {
|
||||||
swapFile := "/swapfile"
|
swapFile := "/swapfile"
|
||||||
if _, err := os.Stat(swapFile); os.IsNotExist(err) {
|
if _, err := os.Stat(swapFile); os.IsNotExist(err) {
|
||||||
|
if getSwapInfo().Enabled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
return fmt.Errorf("swap 文件不存在,请先创建")
|
return fmt.Errorf("swap 文件不存在,请先创建")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,7 +203,7 @@ func disableSwap() error {
|
|||||||
cmd := exec.Command("swapoff", swapFile)
|
cmd := exec.Command("swapoff", swapFile)
|
||||||
output, err := cmd.CombinedOutput()
|
output, err := cmd.CombinedOutput()
|
||||||
if err != nil {
|
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 nil
|
||||||
}
|
}
|
||||||
return fmt.Errorf("禁用 swap 失败: %v, %s", err, string(output))
|
return fmt.Errorf("禁用 swap 失败: %v, %s", err, string(output))
|
||||||
|
|||||||
@@ -75,6 +75,10 @@ func (q *TaskQueue) enqueueTask(task *Task) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (q *TaskQueue) Enqueue(containerID int, containerName string, taskType TaskType, templateID string, cfg *lxc.ContainerConfig) []string {
|
func (q *TaskQueue) Enqueue(containerID int, containerName string, taskType TaskType, templateID string, cfg *lxc.ContainerConfig) []string {
|
||||||
|
return q.EnqueueWithAudit(containerID, containerName, taskType, templateID, cfg, "admin", "", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *TaskQueue) EnqueueWithAudit(containerID int, containerName string, taskType TaskType, templateID string, cfg *lxc.ContainerConfig, user string, ip string, userAgent string) []string {
|
||||||
q.mu.Lock()
|
q.mu.Lock()
|
||||||
defer q.mu.Unlock()
|
defer q.mu.Unlock()
|
||||||
|
|
||||||
@@ -88,6 +92,9 @@ func (q *TaskQueue) Enqueue(containerID int, containerName string, taskType Task
|
|||||||
Status: "pending",
|
Status: "pending",
|
||||||
CreatedAt: time.Now().Format("2006-01-02 15:04:05"),
|
CreatedAt: time.Now().Format("2006-01-02 15:04:05"),
|
||||||
TemplateID: templateID,
|
TemplateID: templateID,
|
||||||
|
User: user,
|
||||||
|
IP: ip,
|
||||||
|
UserAgent: userAgent,
|
||||||
}
|
}
|
||||||
if cfg != nil {
|
if cfg != nil {
|
||||||
task.Config = *cfg
|
task.Config = *cfg
|
||||||
@@ -122,9 +129,13 @@ func (q *TaskQueue) EnqueueBatchWithAudit(taskType TaskType, ids []int, template
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (q *TaskQueue) EnqueueBatchCreate(configs []lxc.ContainerConfig) []string {
|
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()
|
q.mu.Lock()
|
||||||
defer q.mu.Unlock()
|
defer q.mu.Unlock()
|
||||||
return q.enqueueBatchCreateList(configs)
|
return q.enqueueBatchCreateList(configs, user, ip, userAgent)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *TaskQueue) ActiveCreateNames() map[string]bool {
|
func (q *TaskQueue) ActiveCreateNames() map[string]bool {
|
||||||
@@ -147,7 +158,7 @@ func (q *TaskQueue) ActiveCreateNames() map[string]bool {
|
|||||||
return names
|
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
|
var result []string
|
||||||
for _, cfg := range configs {
|
for _, cfg := range configs {
|
||||||
cfgCopy := cfg
|
cfgCopy := cfg
|
||||||
@@ -161,6 +172,9 @@ func (q *TaskQueue) enqueueBatchCreateList(configs []lxc.ContainerConfig) []stri
|
|||||||
Status: "pending",
|
Status: "pending",
|
||||||
CreatedAt: time.Now().Format("2006-01-02 15:04:05"),
|
CreatedAt: time.Now().Format("2006-01-02 15:04:05"),
|
||||||
Config: cfgCopy,
|
Config: cfgCopy,
|
||||||
|
User: user,
|
||||||
|
IP: ip,
|
||||||
|
UserAgent: userAgent,
|
||||||
}
|
}
|
||||||
q.enqueueTask(task)
|
q.enqueueTask(task)
|
||||||
result = append(result, task.ID)
|
result = append(result, task.ID)
|
||||||
@@ -336,7 +350,11 @@ func (q *TaskQueue) opWorker() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
case TaskReinstall:
|
case TaskReinstall:
|
||||||
err = reinstallByRuntime(task.ContainerID, task.TemplateID)
|
if lxc.HasSSHAuthOptions(task.Config) {
|
||||||
|
err = reinstallByRuntime(task.ContainerID, task.TemplateID, task.Config)
|
||||||
|
} else {
|
||||||
|
err = reinstallByRuntime(task.ContainerID, task.TemplateID)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -424,6 +442,8 @@ func (q *TaskQueue) persistTasks() {
|
|||||||
TemplateID: t.TemplateID,
|
TemplateID: t.TemplateID,
|
||||||
Config: string(cfgJSON),
|
Config: string(cfgJSON),
|
||||||
User: t.User,
|
User: t.User,
|
||||||
|
IP: t.IP,
|
||||||
|
UserAgent: t.UserAgent,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
config.SaveTasks(saved)
|
config.SaveTasks(saved)
|
||||||
@@ -456,18 +476,14 @@ func HandleSingleTaskAction(w http.ResponseWriter, r *http.Request, id int, acti
|
|||||||
name = c.Name
|
name = c.Name
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine user from JWT claims
|
// Determine user from authenticated request context.
|
||||||
user := "admin"
|
user := requestActor(r)
|
||||||
if claims, ok := claimsFromRequest(r); ok {
|
|
||||||
if subUser, _ := claims["sub_user"].(string); subUser != "" {
|
|
||||||
user = "user:" + subUser
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ip := clientIP(r)
|
ip := clientIP(r)
|
||||||
userAgent := r.Header.Get("User-Agent")
|
userAgent := r.Header.Get("User-Agent")
|
||||||
|
|
||||||
var taskType TaskType
|
var taskType TaskType
|
||||||
var templateID string
|
var templateID string
|
||||||
|
var taskConfig *lxc.ContainerConfig
|
||||||
switch action {
|
switch action {
|
||||||
case "start":
|
case "start":
|
||||||
taskType = TaskStart
|
taskType = TaskStart
|
||||||
@@ -479,7 +495,10 @@ func HandleSingleTaskAction(w http.ResponseWriter, r *http.Request, id int, acti
|
|||||||
taskType = TaskDelete
|
taskType = TaskDelete
|
||||||
case "reinstall":
|
case "reinstall":
|
||||||
var req struct {
|
var req struct {
|
||||||
TemplateID string `json:"template_id"`
|
TemplateID string `json:"template_id"`
|
||||||
|
SSHAuthMode string `json:"ssh_auth_mode,omitempty"`
|
||||||
|
SSHPassword string `json:"ssh_password,omitempty"`
|
||||||
|
SSHPublicKey string `json:"ssh_public_key,omitempty"`
|
||||||
}
|
}
|
||||||
json.NewDecoder(r.Body).Decode(&req)
|
json.NewDecoder(r.Body).Decode(&req)
|
||||||
templateID = req.TemplateID
|
templateID = req.TemplateID
|
||||||
@@ -497,13 +516,26 @@ func HandleSingleTaskAction(w http.ResponseWriter, r *http.Request, id int, acti
|
|||||||
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Template is not enabled or downloaded"})
|
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Template is not enabled or downloaded"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
authCfg := lxc.ContainerConfig{
|
||||||
|
TemplateID: templateID,
|
||||||
|
SSHAuthMode: req.SSHAuthMode,
|
||||||
|
SSHPassword: req.SSHPassword,
|
||||||
|
SSHPublicKey: req.SSHPublicKey,
|
||||||
|
}
|
||||||
|
if lxc.HasSSHAuthOptions(authCfg) {
|
||||||
|
if err := validateReinstallSSHAuth(c, templateID, authCfg); err != nil {
|
||||||
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
taskConfig = &authCfg
|
||||||
|
}
|
||||||
taskType = TaskReinstall
|
taskType = TaskReinstall
|
||||||
default:
|
default:
|
||||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Unknown action"})
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Unknown action"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ids := globalQueue.EnqueueBatchWithAudit(taskType, []int{id}, templateID, user, ip, userAgent)
|
ids := globalQueue.EnqueueWithAudit(id, name, taskType, templateID, taskConfig, user, ip, userAgent)
|
||||||
jsonResponse(w, http.StatusAccepted, APIResponse{
|
jsonResponse(w, http.StatusAccepted, APIResponse{
|
||||||
Success: true,
|
Success: true,
|
||||||
Message: "Task queued",
|
Message: "Task queued",
|
||||||
@@ -517,6 +549,13 @@ func HandleBatchCreate(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||||
return
|
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 {
|
var req struct {
|
||||||
Containers []lxc.ContainerConfig `json:"containers"`
|
Containers []lxc.ContainerConfig `json:"containers"`
|
||||||
}
|
}
|
||||||
@@ -564,8 +603,37 @@ func HandleBatchCreate(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: name + ": template is not enabled or downloaded"})
|
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: name + ": template is not enabled or downloaded"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if req.Containers[i].PortMappingCount < 2 {
|
if req.Containers[i].PortMappingCount < 0 {
|
||||||
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: name + ": port mapping count cannot be negative"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Containers[i].WantsNAT() && req.Containers[i].PortMappingCount < 2 {
|
||||||
req.Containers[i].PortMappingCount = 2
|
req.Containers[i].PortMappingCount = 2
|
||||||
|
} else if !req.Containers[i].WantsNAT() {
|
||||||
|
req.Containers[i].PortMappingCount = 0
|
||||||
|
req.Containers[i].ExtraPorts = nil
|
||||||
|
}
|
||||||
|
if req.Containers[i].PortMappingCount > 64 {
|
||||||
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: name + ": port mapping count cannot exceed 64"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Containers[i].IPv4Count < 0 || req.Containers[i].IPv6Count < 0 {
|
||||||
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: name + ": IP address count cannot be negative"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Containers[i].IPv4Count > 64 || req.Containers[i].IPv6Count > 64 {
|
||||||
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: name + ": IP address count cannot exceed 64"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !req.Containers[i].AssignIPv4 && len(req.Containers[i].PublicIPv4s) == 0 {
|
||||||
|
req.Containers[i].IPv4Count = 0
|
||||||
|
}
|
||||||
|
if !req.Containers[i].AssignIPv6 && len(req.Containers[i].IPv6Addresses) == 0 {
|
||||||
|
req.Containers[i].IPv6Count = 0
|
||||||
|
}
|
||||||
|
if !hasRequestedNetwork(req.Containers[i]) {
|
||||||
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: name + ": " + noNetworkSelectedMessage})
|
||||||
|
return
|
||||||
}
|
}
|
||||||
if req.Containers[i].SnapshotLimit <= 0 {
|
if req.Containers[i].SnapshotLimit <= 0 {
|
||||||
req.Containers[i].SnapshotLimit = config.DefaultSnapshotLimit
|
req.Containers[i].SnapshotLimit = config.DefaultSnapshotLimit
|
||||||
@@ -574,9 +642,13 @@ func HandleBatchCreate(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: name + ": " + err.Error()})
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: name + ": " + err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if err := validateCreateSSHAuth(req.Containers[i]); err != nil {
|
||||||
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: name + ": " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
requestNames[name] = true
|
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})
|
jsonResponse(w, http.StatusAccepted, APIResponse{Success: true, Data: ids})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -586,10 +658,17 @@ func HandleBatchAction(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||||
return
|
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 {
|
var req struct {
|
||||||
Action string `json:"action"`
|
Action string `json:"action"`
|
||||||
Containers []int `json:"containers"`
|
Containers []int `json:"containers"`
|
||||||
TemplateID string `json:"template_id,omitempty"`
|
TemplateID string `json:"template_id,omitempty"`
|
||||||
|
SSHAuthMode string `json:"ssh_auth_mode,omitempty"`
|
||||||
|
SSHPassword string `json:"ssh_password,omitempty"`
|
||||||
|
SSHPublicKey string `json:"ssh_public_key,omitempty"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
|
||||||
@@ -597,21 +676,76 @@ func HandleBatchAction(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var taskType TaskType
|
var taskType TaskType
|
||||||
|
var requiredScope string
|
||||||
|
var taskConfig *lxc.ContainerConfig
|
||||||
switch req.Action {
|
switch req.Action {
|
||||||
case "start":
|
case "start":
|
||||||
taskType = TaskStart
|
taskType = TaskStart
|
||||||
|
requiredScope = "container:power"
|
||||||
case "stop":
|
case "stop":
|
||||||
taskType = TaskStop
|
taskType = TaskStop
|
||||||
|
requiredScope = "container:power"
|
||||||
case "restart":
|
case "restart":
|
||||||
taskType = TaskRestart
|
taskType = TaskRestart
|
||||||
|
requiredScope = "container:power"
|
||||||
case "delete":
|
case "delete":
|
||||||
taskType = TaskDelete
|
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
|
||||||
|
}
|
||||||
|
authCfg := lxc.ContainerConfig{
|
||||||
|
TemplateID: req.TemplateID,
|
||||||
|
SSHAuthMode: req.SSHAuthMode,
|
||||||
|
SSHPassword: req.SSHPassword,
|
||||||
|
SSHPublicKey: req.SSHPublicKey,
|
||||||
|
}
|
||||||
|
if lxc.HasSSHAuthOptions(authCfg) {
|
||||||
|
taskConfig = &authCfg
|
||||||
|
}
|
||||||
|
taskType = TaskReinstall
|
||||||
|
requiredScope = "container:reinstall"
|
||||||
default:
|
default:
|
||||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Unknown action"})
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Unknown action"})
|
||||||
return
|
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
|
||||||
|
}
|
||||||
|
if taskConfig != nil {
|
||||||
|
if err := validateReinstallSSHAuth(c, req.TemplateID, *taskConfig); err != nil {
|
||||||
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: c.Name + ": " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ids := globalQueue.EnqueueBatch(taskType, req.Containers, req.TemplateID)
|
var ids []string
|
||||||
|
if taskConfig != nil {
|
||||||
|
for _, id := range req.Containers {
|
||||||
|
c := config.FindContainer(id)
|
||||||
|
name := ""
|
||||||
|
if c != nil {
|
||||||
|
name = c.Name
|
||||||
|
}
|
||||||
|
queued := globalQueue.EnqueueWithAudit(id, name, taskType, req.TemplateID, taskConfig, requestActor(r), clientIP(r), r.UserAgent())
|
||||||
|
ids = append(ids, queued...)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ids = globalQueue.EnqueueBatchWithAudit(taskType, req.Containers, req.TemplateID, requestActor(r), clientIP(r), r.UserAgent())
|
||||||
|
}
|
||||||
jsonResponse(w, http.StatusAccepted, APIResponse{Success: true, Data: ids})
|
jsonResponse(w, http.StatusAccepted, APIResponse{Success: true, Data: ids})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -621,13 +755,22 @@ func HandleTaskDelete(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// URL: /api/tasks/{id}
|
if !requireScope(w, r, "task:delete") {
|
||||||
taskID := strings.TrimPrefix(r.URL.Path, "/api/tasks/")
|
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 == "" {
|
if taskID == "" {
|
||||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Task ID required"})
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Task ID required"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
globalQueue.mu.Lock()
|
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)
|
delete(globalQueue.tasks, taskID)
|
||||||
// Also remove from both queues if pending
|
// Also remove from both queues if pending
|
||||||
newCreate := make([]*Task, 0, len(globalQueue.createQueue))
|
newCreate := make([]*Task, 0, len(globalQueue.createQueue))
|
||||||
@@ -655,6 +798,9 @@ func HandleTasks(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requireScope(w, r, "task:read") {
|
||||||
|
return
|
||||||
|
}
|
||||||
tasks := globalQueue.GetTasks()
|
tasks := globalQueue.GetTasks()
|
||||||
tasks = filterTasksForRequest(r, tasks)
|
tasks = filterTasksForRequest(r, tasks)
|
||||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: tasks})
|
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: tasks})
|
||||||
@@ -691,6 +837,8 @@ func RestoreTasks() {
|
|||||||
TemplateID: st.TemplateID,
|
TemplateID: st.TemplateID,
|
||||||
Config: cfg,
|
Config: cfg,
|
||||||
User: st.User,
|
User: st.User,
|
||||||
|
IP: st.IP,
|
||||||
|
UserAgent: st.UserAgent,
|
||||||
}
|
}
|
||||||
if st.Status == "pending" || st.Status == "running" {
|
if st.Status == "pending" || st.Status == "running" {
|
||||||
// Reset running tasks back to pending so they get retried
|
// Reset running tasks back to pending so they get retried
|
||||||
|
|||||||
@@ -18,7 +18,10 @@ import (
|
|||||||
type webVNCTicket struct {
|
type webVNCTicket struct {
|
||||||
ContainerName string
|
ContainerName string
|
||||||
ContainerUUID string
|
ContainerUUID string
|
||||||
|
Username string
|
||||||
SubUser bool
|
SubUser bool
|
||||||
|
ClientIP string
|
||||||
|
UserAgent string
|
||||||
ExpiresAt time.Time
|
ExpiresAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,6 +36,9 @@ func HandleVNCTicket(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !requireScope(w, r, "terminal:vnc") {
|
||||||
|
return
|
||||||
|
}
|
||||||
var req struct {
|
var req struct {
|
||||||
ContainerName string `json:"container_name"`
|
ContainerName string `json:"container_name"`
|
||||||
}
|
}
|
||||||
@@ -58,13 +64,17 @@ func HandleVNCTicket(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
username, isSubUser := vncRequesterIdentity(r)
|
||||||
ticket := randomHex(32)
|
ticket := randomHex(32)
|
||||||
webVNCTickets.Lock()
|
webVNCTickets.Lock()
|
||||||
cleanupExpiredWebVNCTicketsLocked(time.Now())
|
cleanupExpiredWebVNCTicketsLocked(time.Now())
|
||||||
webVNCTickets.items[ticket] = webVNCTicket{
|
webVNCTickets.items[ticket] = webVNCTicket{
|
||||||
ContainerName: c.Name,
|
ContainerName: c.Name,
|
||||||
ContainerUUID: c.UUID,
|
ContainerUUID: c.UUID,
|
||||||
SubUser: isSubUserRequest(r),
|
Username: username,
|
||||||
|
SubUser: isSubUser,
|
||||||
|
ClientIP: clientIP(r),
|
||||||
|
UserAgent: r.UserAgent(),
|
||||||
ExpiresAt: time.Now().Add(60 * time.Second),
|
ExpiresAt: time.Now().Add(60 * time.Second),
|
||||||
}
|
}
|
||||||
webVNCTickets.Unlock()
|
webVNCTickets.Unlock()
|
||||||
@@ -89,7 +99,7 @@ func HandleVNCProxy(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
item, ok := consumeWebVNCTicket(ticket, containerName)
|
item, ok := consumeWebVNCTicket(ticket, containerName, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
http.Error(w, "invalid or expired ticket", http.StatusUnauthorized)
|
http.Error(w, "invalid or expired ticket", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
@@ -137,7 +147,7 @@ func HandleVNCProxy(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
defer ws.Close()
|
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)
|
done := make(chan string, 2)
|
||||||
var writeMu sync.Mutex
|
var writeMu sync.Mutex
|
||||||
@@ -147,7 +157,31 @@ func HandleVNCProxy(w http.ResponseWriter, r *http.Request) {
|
|||||||
reason := <-done
|
reason := <-done
|
||||||
_ = vncConn.Close()
|
_ = vncConn.Close()
|
||||||
_ = ws.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 {
|
func webVNCTicketFromRequest(r *http.Request) string {
|
||||||
@@ -175,7 +209,7 @@ func webVNCResponseProtocol(r *http.Request) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func consumeWebVNCTicket(ticket, containerName string) (webVNCTicket, bool) {
|
func consumeWebVNCTicket(ticket, containerName string, r *http.Request) (webVNCTicket, bool) {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
webVNCTickets.Lock()
|
webVNCTickets.Lock()
|
||||||
defer webVNCTickets.Unlock()
|
defer webVNCTickets.Unlock()
|
||||||
@@ -185,7 +219,10 @@ func consumeWebVNCTicket(ticket, containerName string) (webVNCTicket, bool) {
|
|||||||
return webVNCTicket{}, false
|
return webVNCTicket{}, false
|
||||||
}
|
}
|
||||||
delete(webVNCTickets.items, ticket)
|
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) {
|
func cleanupExpiredWebVNCTicketsLocked(now time.Time) {
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
|
||||||
"strings"
|
"clicd/internal/config"
|
||||||
|
|
||||||
"github.com/gorilla/websocket"
|
"github.com/gorilla/websocket"
|
||||||
)
|
)
|
||||||
@@ -17,19 +16,6 @@ var upgrader = websocket.Upgrader{
|
|||||||
if origin == "" {
|
if origin == "" {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
originURL, err := url.Parse(origin)
|
return config.IsOriginAllowed(origin, r.Host)
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
originHost := strings.ToLower(stripPort(originURL.Host))
|
|
||||||
requestHost := strings.ToLower(stripPort(r.Host))
|
|
||||||
return originHost != "" && originHost == requestHost
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
func stripPort(host string) string {
|
|
||||||
if parsedHost, _, err := net.SplitHostPort(host); err == nil {
|
|
||||||
return parsedHost
|
|
||||||
}
|
|
||||||
return strings.Trim(host, "[]")
|
|
||||||
}
|
|
||||||
|
|||||||
+573
-116
File diff suppressed because it is too large
Load Diff
@@ -17,10 +17,32 @@ import (
|
|||||||
type PortMapping struct {
|
type PortMapping struct {
|
||||||
ContainerPort int `json:"container_port"`
|
ContainerPort int `json:"container_port"`
|
||||||
HostPort int `json:"host_port"`
|
HostPort int `json:"host_port"`
|
||||||
|
HostIP string `json:"host_ip,omitempty"`
|
||||||
Protocol string `json:"protocol"`
|
Protocol string `json:"protocol"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PublicIPv4Assignment struct {
|
||||||
|
Address string `json:"address"`
|
||||||
|
Interface string `json:"interface,omitempty"`
|
||||||
|
PrefixLen int `json:"prefix_len,omitempty"`
|
||||||
|
Gateway string `json:"gateway,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type IPv6Assignment struct {
|
||||||
|
Address string `json:"address"`
|
||||||
|
PrefixLen int `json:"prefix_len"`
|
||||||
|
Interface string `json:"interface,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PublicIPv6Prefix struct {
|
||||||
|
Address string `json:"address"`
|
||||||
|
Prefix string `json:"prefix,omitempty"`
|
||||||
|
PrefixLen int `json:"prefix_len"`
|
||||||
|
Interface string `json:"interface,omitempty"`
|
||||||
|
Gateway string `json:"gateway,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// SavedTask for persisting task queue across restarts
|
// SavedTask for persisting task queue across restarts
|
||||||
type SavedTask struct {
|
type SavedTask struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
@@ -33,6 +55,8 @@ type SavedTask struct {
|
|||||||
TemplateID string `json:"template_id,omitempty"`
|
TemplateID string `json:"template_id,omitempty"`
|
||||||
Config string `json:"config,omitempty"`
|
Config string `json:"config,omitempty"`
|
||||||
User string `json:"user,omitempty"`
|
User string `json:"user,omitempty"`
|
||||||
|
IP string `json:"ip,omitempty"`
|
||||||
|
UserAgent string `json:"user_agent,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SavedLoginLog for persisting login logs
|
// SavedLoginLog for persisting login logs
|
||||||
@@ -66,50 +90,52 @@ type VMReadinessCheck struct {
|
|||||||
|
|
||||||
// Container represents an LXC container configuration
|
// Container represents an LXC container configuration
|
||||||
type Container struct {
|
type Container struct {
|
||||||
ID int `json:"id"`
|
ID int `json:"id"`
|
||||||
UUID string `json:"uuid"`
|
UUID string `json:"uuid"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Virtualization string `json:"virtualization,omitempty"`
|
Virtualization string `json:"virtualization,omitempty"`
|
||||||
LXCName string `json:"lxc_name,omitempty"`
|
LXCName string `json:"lxc_name,omitempty"`
|
||||||
KVMName string `json:"kvm_name,omitempty"`
|
KVMName string `json:"kvm_name,omitempty"`
|
||||||
DiskImage string `json:"disk_image,omitempty"`
|
DiskImage string `json:"disk_image,omitempty"`
|
||||||
MACAddress string `json:"mac_address,omitempty"`
|
MACAddress string `json:"mac_address,omitempty"`
|
||||||
Template string `json:"template"`
|
Template string `json:"template"`
|
||||||
VCPU float64 `json:"vcpu"`
|
VCPU float64 `json:"vcpu"`
|
||||||
RAMMB int `json:"ram_mb"`
|
RAMMB int `json:"ram_mb"`
|
||||||
DiskGB int `json:"disk_gb"`
|
DiskGB int `json:"disk_gb"`
|
||||||
NetworkBWMbps int `json:"network_bw_mbps"`
|
NetworkBWMbps int `json:"network_bw_mbps"`
|
||||||
MonthlyTrafficGB int `json:"monthly_traffic_gb"`
|
MonthlyTrafficGB int `json:"monthly_traffic_gb"`
|
||||||
TrafficMode string `json:"traffic_mode"` // "total" or "in_out"
|
TrafficMode string `json:"traffic_mode"` // "total" or "in_out"
|
||||||
TrafficInGB int `json:"traffic_in_gb"` // 0 = unlimited
|
TrafficInGB int `json:"traffic_in_gb"` // 0 = unlimited
|
||||||
TrafficOutGB int `json:"traffic_out_gb"` // 0 = unlimited
|
TrafficOutGB int `json:"traffic_out_gb"` // 0 = unlimited
|
||||||
TrafficUsedRX int64 `json:"traffic_used_rx"`
|
TrafficUsedRX int64 `json:"traffic_used_rx"`
|
||||||
TrafficUsedTX int64 `json:"traffic_used_tx"`
|
TrafficUsedTX int64 `json:"traffic_used_tx"`
|
||||||
TrafficResetDate string `json:"traffic_reset_date"`
|
TrafficResetDate string `json:"traffic_reset_date"`
|
||||||
IOSpeedMBps int `json:"io_speed_mbps"`
|
IOSpeedMBps int `json:"io_speed_mbps"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
IP string `json:"ip"`
|
IP string `json:"ip"`
|
||||||
IPv6 string `json:"ipv6"`
|
PublicIPv4s []PublicIPv4Assignment `json:"public_ipv4s,omitempty"`
|
||||||
IPv6PrefixLen int `json:"ipv6_prefix_len"`
|
IPv6 string `json:"ipv6"`
|
||||||
IPv6Interface string `json:"ipv6_interface"`
|
IPv6PrefixLen int `json:"ipv6_prefix_len"`
|
||||||
VNCPort int `json:"vnc_port"`
|
IPv6Interface string `json:"ipv6_interface"`
|
||||||
SSHPort int `json:"ssh_port"`
|
IPv6Addresses []IPv6Assignment `json:"ipv6_addresses,omitempty"`
|
||||||
SSHPassword string `json:"ssh_password"`
|
VNCPort int `json:"vnc_port"`
|
||||||
SSHHostKey string `json:"ssh_host_key,omitempty"`
|
SSHPort int `json:"ssh_port"`
|
||||||
PortMappings []PortMapping `json:"port_mappings"`
|
SSHPassword string `json:"ssh_password"`
|
||||||
PortMappingLimit int `json:"port_mapping_limit"`
|
SSHHostKey string `json:"ssh_host_key,omitempty"`
|
||||||
SnapshotLimit int `json:"snapshot_limit"`
|
PortMappings []PortMapping `json:"port_mappings"`
|
||||||
CreatedAt string `json:"created_at"`
|
PortMappingLimit int `json:"port_mapping_limit"`
|
||||||
ExpiresAt string `json:"expires_at"`
|
SnapshotLimit int `json:"snapshot_limit"`
|
||||||
SnapshotScheduleEnabled bool `json:"snapshot_schedule_enabled"`
|
CreatedAt string `json:"created_at"`
|
||||||
SnapshotScheduleIntervalHours int `json:"snapshot_schedule_interval_hours"`
|
ExpiresAt string `json:"expires_at"`
|
||||||
SnapshotScheduleTime string `json:"snapshot_schedule_time"`
|
SnapshotScheduleEnabled bool `json:"snapshot_schedule_enabled"`
|
||||||
SnapshotScheduleLastRun string `json:"snapshot_schedule_last_run"`
|
SnapshotScheduleIntervalHours int `json:"snapshot_schedule_interval_hours"`
|
||||||
SnapshotScheduleNextRun string `json:"snapshot_schedule_next_run"`
|
SnapshotScheduleTime string `json:"snapshot_schedule_time"`
|
||||||
SnapshotScheduleCreatedBy string `json:"snapshot_schedule_created_by"`
|
SnapshotScheduleLastRun string `json:"snapshot_schedule_last_run"`
|
||||||
PolicyBlocked bool `json:"policy_blocked"`
|
SnapshotScheduleNextRun string `json:"snapshot_schedule_next_run"`
|
||||||
PolicyBlockedReason string `json:"policy_blocked_reason,omitempty"`
|
SnapshotScheduleCreatedBy string `json:"snapshot_schedule_created_by"`
|
||||||
PolicyBlockedAt string `json:"policy_blocked_at,omitempty"`
|
PolicyBlocked bool `json:"policy_blocked"`
|
||||||
|
PolicyBlockedReason string `json:"policy_blocked_reason,omitempty"`
|
||||||
|
PolicyBlockedAt string `json:"policy_blocked_at,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -134,6 +160,101 @@ func (c *Container) IsKVM() bool {
|
|||||||
return c.Runtime() == VirtualizationKVM
|
return c.Runtime() == VirtualizationKVM
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *Container) NormalizeNetworkAssignments() bool {
|
||||||
|
changed := false
|
||||||
|
seenIPv4 := map[string]bool{}
|
||||||
|
filteredIPv4 := make([]PublicIPv4Assignment, 0, len(c.PublicIPv4s))
|
||||||
|
for _, item := range c.PublicIPv4s {
|
||||||
|
item.Address = strings.TrimSpace(item.Address)
|
||||||
|
item.Interface = strings.TrimSpace(item.Interface)
|
||||||
|
item.Gateway = strings.TrimSpace(item.Gateway)
|
||||||
|
if item.Address == "" || seenIPv4[item.Address] {
|
||||||
|
if item.Address != "" {
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seenIPv4[item.Address] = true
|
||||||
|
filteredIPv4 = append(filteredIPv4, item)
|
||||||
|
}
|
||||||
|
if len(filteredIPv4) != len(c.PublicIPv4s) {
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
c.PublicIPv4s = filteredIPv4
|
||||||
|
|
||||||
|
seenIPv6 := map[string]bool{}
|
||||||
|
filteredIPv6 := make([]IPv6Assignment, 0, len(c.IPv6Addresses)+1)
|
||||||
|
for _, item := range c.IPv6Addresses {
|
||||||
|
item.Address = strings.TrimSpace(item.Address)
|
||||||
|
item.Interface = strings.TrimSpace(item.Interface)
|
||||||
|
if item.Address == "" || seenIPv6[item.Address] {
|
||||||
|
if item.Address != "" {
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seenIPv6[item.Address] = true
|
||||||
|
filteredIPv6 = append(filteredIPv6, item)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(c.IPv6) != "" && !seenIPv6[c.IPv6] {
|
||||||
|
filteredIPv6 = append([]IPv6Assignment{{
|
||||||
|
Address: c.IPv6,
|
||||||
|
PrefixLen: c.IPv6PrefixLen,
|
||||||
|
Interface: c.IPv6Interface,
|
||||||
|
}}, filteredIPv6...)
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if len(filteredIPv6) != len(c.IPv6Addresses) {
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
c.IPv6Addresses = filteredIPv6
|
||||||
|
if len(c.IPv6Addresses) > 0 {
|
||||||
|
first := c.IPv6Addresses[0]
|
||||||
|
if c.IPv6 != first.Address || c.IPv6PrefixLen != first.PrefixLen || c.IPv6Interface != first.Interface {
|
||||||
|
c.IPv6 = first.Address
|
||||||
|
c.IPv6PrefixLen = first.PrefixLen
|
||||||
|
c.IPv6Interface = first.Interface
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
} else if c.IPv6 != "" || c.IPv6PrefixLen != 0 || c.IPv6Interface != "" {
|
||||||
|
c.IPv6 = ""
|
||||||
|
c.IPv6PrefixLen = 0
|
||||||
|
c.IPv6Interface = ""
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
return changed
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Container) PublicIPv4Addresses() []string {
|
||||||
|
values := make([]string, 0, len(c.PublicIPv4s))
|
||||||
|
for _, item := range c.PublicIPv4s {
|
||||||
|
if item.Address != "" {
|
||||||
|
values = append(values, item.Address)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return values
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Container) PrimaryPublicIPv4() string {
|
||||||
|
if len(c.PublicIPv4s) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return c.PublicIPv4s[0].Address
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Container) IPv6AddressStrings() []string {
|
||||||
|
values := make([]string, 0, len(c.IPv6Addresses))
|
||||||
|
for _, item := range c.IPv6Addresses {
|
||||||
|
if item.Address != "" {
|
||||||
|
values = append(values, item.Address)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(values) == 0 && c.IPv6 != "" {
|
||||||
|
values = append(values, c.IPv6)
|
||||||
|
}
|
||||||
|
return values
|
||||||
|
}
|
||||||
|
|
||||||
// LxcName returns the internal LXC container name (ct-{id})
|
// LxcName returns the internal LXC container name (ct-{id})
|
||||||
func (c *Container) LxcName() string {
|
func (c *Container) LxcName() string {
|
||||||
if c.LXCName != "" {
|
if c.LXCName != "" {
|
||||||
@@ -152,13 +273,18 @@ func (c *Container) VirshName() string {
|
|||||||
|
|
||||||
// SubUser represents a sub-user with access to specific containers
|
// SubUser represents a sub-user with access to specific containers
|
||||||
type ApiKeyConfig struct {
|
type ApiKeyConfig struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
KeyHash string `json:"key_hash"`
|
KeyHash string `json:"key_hash"`
|
||||||
Prefix string `json:"prefix"`
|
Prefix string `json:"prefix"`
|
||||||
IPWhitelist string `json:"ip_whitelist"`
|
IPWhitelist string `json:"ip_whitelist"`
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
LastUsed string `json:"last_used"`
|
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
|
// DeleteApiKey removes an API key by ID
|
||||||
@@ -198,26 +324,50 @@ type Snapshot struct {
|
|||||||
SizeBytes int64 `json:"size_bytes"`
|
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
|
// ClicdConfig is the main configuration structure
|
||||||
type ClicdConfig struct {
|
type ClicdConfig struct {
|
||||||
AdminUser string `json:"admin_user"`
|
AdminUser string `json:"admin_user"`
|
||||||
AdminPassHash string `json:"admin_pass_hash"`
|
AdminPassHash string `json:"admin_pass_hash"`
|
||||||
JWTSecret string `json:"jwt_secret"`
|
JWTSecret string `json:"jwt_secret"`
|
||||||
Port int `json:"port"`
|
Port int `json:"port"`
|
||||||
DataDir string `json:"data_dir"`
|
DataDir string `json:"data_dir"`
|
||||||
Containers []Container `json:"containers"`
|
Containers []Container `json:"containers"`
|
||||||
NextContainerID int `json:"next_container_id"`
|
NextContainerID int `json:"next_container_id"`
|
||||||
NextVNCPort int `json:"next_vnc_port"`
|
NextVNCPort int `json:"next_vnc_port"`
|
||||||
NextSSHPort int `json:"next_ssh_port"`
|
NextSSHPort int `json:"next_ssh_port"`
|
||||||
SetupComplete bool `json:"setup_complete"`
|
SetupComplete bool `json:"setup_complete"`
|
||||||
SubUsers []SubUser `json:"sub_users"`
|
SubUsers []SubUser `json:"sub_users"`
|
||||||
ApiKeys []ApiKeyConfig `json:"api_keys"`
|
ApiKeys []ApiKeyConfig `json:"api_keys"`
|
||||||
AuditLogs []AuditLog `json:"audit_logs"`
|
AuditLogs []AuditLog `json:"audit_logs"`
|
||||||
Tasks []SavedTask `json:"tasks"`
|
Tasks []SavedTask `json:"tasks"`
|
||||||
LoginLogs []SavedLoginLog `json:"login_logs"`
|
LoginLogs []SavedLoginLog `json:"login_logs"`
|
||||||
EnabledImages []string `json:"enabled_images"`
|
EnabledImages []string `json:"enabled_images"`
|
||||||
Snapshots []Snapshot `json:"snapshots"`
|
Snapshots []Snapshot `json:"snapshots"`
|
||||||
SecurityAutoShutdown bool `json:"security_auto_shutdown"`
|
PublicIPv4Pool []PublicIPv4Assignment `json:"public_ipv4_pool"`
|
||||||
|
PublicIPv6Prefixes []PublicIPv6Prefix `json:"public_ipv6_prefixes"`
|
||||||
|
WebSSHAllowedOrigins []string `json:"webssh_allowed_origins"`
|
||||||
|
SecurityAutoShutdown bool `json:"security_auto_shutdown"`
|
||||||
|
Language string `json:"language"`
|
||||||
|
SSL SSLConfig `json:"ssl"`
|
||||||
|
SSLCertificates map[string]SSLConfig `json:"ssl_certificates"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var configPath string
|
var configPath string
|
||||||
@@ -295,8 +445,11 @@ func InitConfig() (*ClicdConfig, error) {
|
|||||||
}
|
}
|
||||||
if ok {
|
if ok {
|
||||||
AppConfig = cfg
|
AppConfig = cfg
|
||||||
normalizeConfigDefaults(dataDir)
|
changed := normalizeConfigDefaults(dataDir)
|
||||||
if migrateLoadedConfig() {
|
if migrateLoadedConfig() {
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if changed {
|
||||||
if err := SaveConfig(); err != nil {
|
if err := SaveConfig(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -311,9 +464,8 @@ func InitConfig() (*ClicdConfig, error) {
|
|||||||
if ok {
|
if ok {
|
||||||
AppConfig = legacy
|
AppConfig = legacy
|
||||||
normalizeConfigDefaults(dataDir)
|
normalizeConfigDefaults(dataDir)
|
||||||
if migrateLoadedConfig() {
|
migrateLoadedConfig()
|
||||||
// Save below persists normalized legacy data into SQLite.
|
// Always save legacy JSON data into SQLite.
|
||||||
}
|
|
||||||
if err := SaveConfig(); err != nil {
|
if err := SaveConfig(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -329,21 +481,24 @@ func InitConfig() (*ClicdConfig, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
AppConfig = &ClicdConfig{
|
AppConfig = &ClicdConfig{
|
||||||
AdminUser: adminUser,
|
AdminUser: adminUser,
|
||||||
AdminPassHash: string(hash),
|
AdminPassHash: string(hash),
|
||||||
JWTSecret: jwtSecret,
|
JWTSecret: jwtSecret,
|
||||||
Port: 8999,
|
Port: 8999,
|
||||||
DataDir: dataDir,
|
DataDir: dataDir,
|
||||||
Containers: []Container{},
|
Containers: []Container{},
|
||||||
NextContainerID: 1,
|
NextContainerID: 1,
|
||||||
NextVNCPort: 5900,
|
NextVNCPort: 5900,
|
||||||
NextSSHPort: 22000,
|
NextSSHPort: 22000,
|
||||||
SetupComplete: false,
|
SetupComplete: false,
|
||||||
SubUsers: []SubUser{},
|
SubUsers: []SubUser{},
|
||||||
AuditLogs: []AuditLog{},
|
AuditLogs: []AuditLog{},
|
||||||
Tasks: []SavedTask{},
|
Tasks: []SavedTask{},
|
||||||
LoginLogs: []SavedLoginLog{},
|
LoginLogs: []SavedLoginLog{},
|
||||||
Snapshots: []Snapshot{},
|
Snapshots: []Snapshot{},
|
||||||
|
PublicIPv4Pool: []PublicIPv4Assignment{},
|
||||||
|
PublicIPv6Prefixes: []PublicIPv6Prefix{},
|
||||||
|
WebSSHAllowedOrigins: []string{},
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := SaveConfig(); err != nil {
|
if err := SaveConfig(); err != nil {
|
||||||
@@ -364,45 +519,159 @@ func InitConfig() (*ClicdConfig, error) {
|
|||||||
return AppConfig, nil
|
return AppConfig, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeConfigDefaults(dataDir string) {
|
func normalizeConfigDefaults(dataDir string) bool {
|
||||||
|
changed := false
|
||||||
if AppConfig.Port == 0 {
|
if AppConfig.Port == 0 {
|
||||||
AppConfig.Port = 8999
|
AppConfig.Port = 8999
|
||||||
|
changed = true
|
||||||
}
|
}
|
||||||
if AppConfig.NextVNCPort == 0 {
|
if AppConfig.NextVNCPort == 0 {
|
||||||
AppConfig.NextVNCPort = 5900
|
AppConfig.NextVNCPort = 5900
|
||||||
|
changed = true
|
||||||
}
|
}
|
||||||
if AppConfig.NextSSHPort == 0 {
|
if AppConfig.NextSSHPort == 0 {
|
||||||
AppConfig.NextSSHPort = 22000
|
AppConfig.NextSSHPort = 22000
|
||||||
|
changed = true
|
||||||
}
|
}
|
||||||
if AppConfig.NextContainerID == 0 {
|
if AppConfig.NextContainerID == 0 {
|
||||||
AppConfig.NextContainerID = 1
|
AppConfig.NextContainerID = 1
|
||||||
|
changed = true
|
||||||
}
|
}
|
||||||
if AppConfig.DataDir == "" {
|
if AppConfig.DataDir == "" {
|
||||||
AppConfig.DataDir = dataDir
|
AppConfig.DataDir = dataDir
|
||||||
|
changed = true
|
||||||
}
|
}
|
||||||
if AppConfig.Containers == nil {
|
if AppConfig.Containers == nil {
|
||||||
AppConfig.Containers = make([]Container, 0)
|
AppConfig.Containers = make([]Container, 0)
|
||||||
|
changed = true
|
||||||
}
|
}
|
||||||
if AppConfig.Snapshots == nil {
|
if AppConfig.Snapshots == nil {
|
||||||
AppConfig.Snapshots = make([]Snapshot, 0)
|
AppConfig.Snapshots = make([]Snapshot, 0)
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if AppConfig.PublicIPv4Pool == nil {
|
||||||
|
AppConfig.PublicIPv4Pool = make([]PublicIPv4Assignment, 0)
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if AppConfig.PublicIPv6Prefixes == nil {
|
||||||
|
AppConfig.PublicIPv6Prefixes = make([]PublicIPv6Prefix, 0)
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if AppConfig.WebSSHAllowedOrigins == nil {
|
||||||
|
AppConfig.WebSSHAllowedOrigins = make([]string, 0)
|
||||||
|
changed = true
|
||||||
|
} else if normalized, err := NormalizeAllowedOrigins(AppConfig.WebSSHAllowedOrigins); err == nil && strings.Join(normalized, "\n") != strings.Join(AppConfig.WebSSHAllowedOrigins, "\n") {
|
||||||
|
AppConfig.WebSSHAllowedOrigins = normalized
|
||||||
|
changed = true
|
||||||
}
|
}
|
||||||
if AppConfig.SubUsers == nil {
|
if AppConfig.SubUsers == nil {
|
||||||
AppConfig.SubUsers = make([]SubUser, 0)
|
AppConfig.SubUsers = make([]SubUser, 0)
|
||||||
|
changed = true
|
||||||
}
|
}
|
||||||
if AppConfig.ApiKeys == nil {
|
if AppConfig.ApiKeys == nil {
|
||||||
AppConfig.ApiKeys = make([]ApiKeyConfig, 0)
|
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 {
|
if AppConfig.AuditLogs == nil {
|
||||||
AppConfig.AuditLogs = make([]AuditLog, 0)
|
AppConfig.AuditLogs = make([]AuditLog, 0)
|
||||||
|
changed = true
|
||||||
}
|
}
|
||||||
if AppConfig.Tasks == nil {
|
if AppConfig.Tasks == nil {
|
||||||
AppConfig.Tasks = make([]SavedTask, 0)
|
AppConfig.Tasks = make([]SavedTask, 0)
|
||||||
|
changed = true
|
||||||
}
|
}
|
||||||
if AppConfig.LoginLogs == nil {
|
if AppConfig.LoginLogs == nil {
|
||||||
AppConfig.LoginLogs = make([]SavedLoginLog, 0)
|
AppConfig.LoginLogs = make([]SavedLoginLog, 0)
|
||||||
|
changed = true
|
||||||
}
|
}
|
||||||
if AppConfig.EnabledImages == nil {
|
if AppConfig.EnabledImages == nil {
|
||||||
AppConfig.EnabledImages = make([]string, 0)
|
AppConfig.EnabledImages = make([]string, 0)
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if AppConfig.Language == "" {
|
||||||
|
AppConfig.Language = "zh"
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if AppConfig.Language != "zh" && AppConfig.Language != "en" {
|
||||||
|
AppConfig.Language = "zh"
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if normalizeSSLDefaults() {
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
return changed
|
||||||
|
}
|
||||||
|
|
||||||
|
func NormalizeLanguage(language string) string {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(language)) {
|
||||||
|
case "en", "en-us", "en_us", "english":
|
||||||
|
return "en"
|
||||||
|
default:
|
||||||
|
return "zh"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeSSLDefaults() bool {
|
||||||
|
changed := false
|
||||||
|
previousMode := AppConfig.SSL.Mode
|
||||||
|
AppConfig.SSL.Mode = NormalizeSSLMode(AppConfig.SSL.Mode)
|
||||||
|
if AppConfig.SSL.Mode != previousMode {
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if AppConfig.SSL.Mode == SSLModeDisabled {
|
||||||
|
if AppConfig.SSL.Enabled {
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
AppConfig.SSL.Enabled = false
|
||||||
|
}
|
||||||
|
if AppConfig.SSLCertificates == nil {
|
||||||
|
AppConfig.SSLCertificates = map[string]SSLConfig{}
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
for mode, cert := range AppConfig.SSLCertificates {
|
||||||
|
cert.Mode = NormalizeSSLMode(cert.Mode)
|
||||||
|
if cert.Mode == SSLModeDisabled {
|
||||||
|
delete(AppConfig.SSLCertificates, mode)
|
||||||
|
changed = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if AppConfig.SSLCertificates[cert.Mode] != cert {
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
AppConfig.SSLCertificates[cert.Mode] = cert
|
||||||
|
if mode != cert.Mode {
|
||||||
|
delete(AppConfig.SSLCertificates, mode)
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if AppConfig.SSL.Mode != SSLModeDisabled && AppConfig.SSL.CertPath != "" && AppConfig.SSL.KeyPath != "" {
|
||||||
|
cert := AppConfig.SSL
|
||||||
|
cert.Enabled = false
|
||||||
|
if AppConfig.SSLCertificates[cert.Mode] != cert {
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
AppConfig.SSLCertificates[cert.Mode] = cert
|
||||||
|
}
|
||||||
|
return changed
|
||||||
|
}
|
||||||
|
|
||||||
|
func NormalizeSSLMode(value string) string {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||||
|
case SSLModeLetsEncrypt:
|
||||||
|
return SSLModeLetsEncrypt
|
||||||
|
case SSLModeSelfSigned:
|
||||||
|
return SSLModeSelfSigned
|
||||||
|
case SSLModeUploaded:
|
||||||
|
return SSLModeUploaded
|
||||||
|
default:
|
||||||
|
return SSLModeDisabled
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -417,6 +686,9 @@ func migrateLoadedConfig() bool {
|
|||||||
if ensureContainerSnapshotLimits() {
|
if ensureContainerSnapshotLimits() {
|
||||||
changed = true
|
changed = true
|
||||||
}
|
}
|
||||||
|
if ensureContainerNetworkAssignments() {
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
if ensureContainerSnapshotScheduleDefaults() {
|
if ensureContainerSnapshotScheduleDefaults() {
|
||||||
changed = true
|
changed = true
|
||||||
}
|
}
|
||||||
@@ -479,13 +751,16 @@ func ensureContainerUUIDs() bool {
|
|||||||
func ensureContainerPortMappingLimits() bool {
|
func ensureContainerPortMappingLimits() bool {
|
||||||
changed := false
|
changed := false
|
||||||
for i := range AppConfig.Containers {
|
for i := range AppConfig.Containers {
|
||||||
if AppConfig.Containers[i].PortMappingLimit <= 0 {
|
if AppConfig.Containers[i].PortMappingLimit < 0 {
|
||||||
limit := len(AppConfig.Containers[i].PortMappings)
|
limit := len(AppConfig.Containers[i].PortMappings)
|
||||||
if limit < 2 {
|
if limit < 2 {
|
||||||
limit = 2
|
limit = 2
|
||||||
}
|
}
|
||||||
AppConfig.Containers[i].PortMappingLimit = limit
|
AppConfig.Containers[i].PortMappingLimit = limit
|
||||||
changed = true
|
changed = true
|
||||||
|
} else if AppConfig.Containers[i].PortMappingLimit == 0 && len(AppConfig.Containers[i].PortMappings) > 0 {
|
||||||
|
AppConfig.Containers[i].PortMappingLimit = len(AppConfig.Containers[i].PortMappings)
|
||||||
|
changed = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return changed
|
return changed
|
||||||
@@ -502,6 +777,16 @@ func ensureContainerSnapshotLimits() bool {
|
|||||||
return changed
|
return changed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ensureContainerNetworkAssignments() bool {
|
||||||
|
changed := false
|
||||||
|
for i := range AppConfig.Containers {
|
||||||
|
if AppConfig.Containers[i].NormalizeNetworkAssignments() {
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return changed
|
||||||
|
}
|
||||||
|
|
||||||
func migrateSubUsers() bool {
|
func migrateSubUsers() bool {
|
||||||
changed := false
|
changed := false
|
||||||
for i := range AppConfig.SubUsers {
|
for i := range AppConfig.SubUsers {
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NormalizeAllowedOrigin accepts a browser Origin value such as
|
||||||
|
// https://www.example.com and returns a canonical form for exact matching.
|
||||||
|
func NormalizeAllowedOrigin(value string) (string, error) {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
u, err := url.Parse(value)
|
||||||
|
if err != nil || u.Scheme == "" || u.Host == "" {
|
||||||
|
return "", fmt.Errorf("Origin must include scheme and host: %s", value)
|
||||||
|
}
|
||||||
|
scheme := strings.ToLower(u.Scheme)
|
||||||
|
if scheme != "http" && scheme != "https" {
|
||||||
|
return "", fmt.Errorf("Origin scheme must be http or https: %s", value)
|
||||||
|
}
|
||||||
|
if (u.Path != "" && u.Path != "/") || u.RawQuery != "" || u.Fragment != "" {
|
||||||
|
return "", fmt.Errorf("Origin must not include path, query, or fragment: %s", value)
|
||||||
|
}
|
||||||
|
host := normalizeOriginHostPort(u.Host, scheme)
|
||||||
|
if host == "" {
|
||||||
|
return "", fmt.Errorf("Origin host is required: %s", value)
|
||||||
|
}
|
||||||
|
return scheme + "://" + host, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func NormalizeAllowedOrigins(values []string) ([]string, error) {
|
||||||
|
result := make([]string, 0, len(values))
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, value := range values {
|
||||||
|
origin, err := NormalizeAllowedOrigin(value)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if origin == "" || seen[origin] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[origin] = true
|
||||||
|
result = append(result, origin)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func IsOriginAllowed(origin string, requestHost string) bool {
|
||||||
|
origin = strings.TrimSpace(origin)
|
||||||
|
if origin == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if isSameRequestOrigin(origin, requestHost) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
normalized, err := NormalizeAllowedOrigin(origin)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if AppConfig == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, allowed := range AppConfig.WebSSHAllowedOrigins {
|
||||||
|
allowed, err := NormalizeAllowedOrigin(allowed)
|
||||||
|
if err == nil && normalized == allowed {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func isSameRequestOrigin(origin string, requestHost string) bool {
|
||||||
|
u, err := url.Parse(origin)
|
||||||
|
if err != nil || u.Host == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
originHost := normalizeHostOnly(u.Hostname())
|
||||||
|
host := normalizeHostOnly(requestHost)
|
||||||
|
if originHost == "" || host == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if originHost == host {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return isLoopbackHost(originHost) && isLoopbackHost(host)
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeOriginHostPort(raw string, scheme string) string {
|
||||||
|
host := raw
|
||||||
|
port := ""
|
||||||
|
if h, p, err := net.SplitHostPort(raw); err == nil {
|
||||||
|
host = h
|
||||||
|
port = p
|
||||||
|
}
|
||||||
|
host = normalizeHostOnly(host)
|
||||||
|
if host == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if (scheme == "https" && port == "443") || (scheme == "http" && port == "80") {
|
||||||
|
port = ""
|
||||||
|
}
|
||||||
|
if port != "" {
|
||||||
|
return net.JoinHostPort(host, port)
|
||||||
|
}
|
||||||
|
if strings.Contains(host, ":") && net.ParseIP(host) != nil {
|
||||||
|
return "[" + host + "]"
|
||||||
|
}
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeHostOnly(raw string) string {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if h, _, err := net.SplitHostPort(raw); err == nil {
|
||||||
|
raw = h
|
||||||
|
}
|
||||||
|
raw = strings.Trim(raw, "[]")
|
||||||
|
if ip := net.ParseIP(raw); ip != nil {
|
||||||
|
return strings.ToLower(ip.String())
|
||||||
|
}
|
||||||
|
return strings.TrimSuffix(strings.ToLower(raw), ".")
|
||||||
|
}
|
||||||
|
|
||||||
|
func isLoopbackHost(host string) bool {
|
||||||
|
if host == "localhost" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
ip := net.ParseIP(host)
|
||||||
|
return ip != nil && ip.IsLoopback()
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const letsEncryptLiveDir = "/etc/letsencrypt/live"
|
||||||
|
|
||||||
|
var dnsNamePattern = regexp.MustCompile(`^[A-Za-z0-9.-]+$`)
|
||||||
|
|
||||||
|
func SSLStorageDir() string {
|
||||||
|
dataDir := ""
|
||||||
|
if AppConfig != nil {
|
||||||
|
dataDir = AppConfig.DataDir
|
||||||
|
}
|
||||||
|
if dataDir == "" {
|
||||||
|
dataDir = getDataDir()
|
||||||
|
}
|
||||||
|
return filepath.Join(dataDir, "ssl")
|
||||||
|
}
|
||||||
|
|
||||||
|
func UploadedSSLPaths() (string, string, error) {
|
||||||
|
dir, err := safeSSLStorageDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
return filepath.Join(dir, "uploaded-fullchain.pem"), filepath.Join(dir, "uploaded-privkey.pem"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func SelfSignedSSLPaths() (string, string, error) {
|
||||||
|
dir, err := safeSSLStorageDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
return filepath.Join(dir, "self-signed-fullchain.pem"), filepath.Join(dir, "self-signed-privkey.pem"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func LetsEncryptSSLPaths(target string) (string, string, error) {
|
||||||
|
name, err := NormalizeSSLCertificateTarget(target)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
base := filepath.Join(letsEncryptLiveDir, name)
|
||||||
|
return filepath.Join(base, "fullchain.pem"), filepath.Join(base, "privkey.pem"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ResolveSSLConfigPaths(ssl SSLConfig) (string, string, error) {
|
||||||
|
mode := NormalizeSSLMode(ssl.Mode)
|
||||||
|
switch mode {
|
||||||
|
case SSLModeUploaded:
|
||||||
|
if ssl.CertPath != "" && ssl.KeyPath != "" {
|
||||||
|
return ResolveSSLPathPair(ssl.CertPath, ssl.KeyPath)
|
||||||
|
}
|
||||||
|
return UploadedSSLPaths()
|
||||||
|
case SSLModeSelfSigned:
|
||||||
|
if ssl.CertPath != "" && ssl.KeyPath != "" {
|
||||||
|
return ResolveSSLPathPair(ssl.CertPath, ssl.KeyPath)
|
||||||
|
}
|
||||||
|
return SelfSignedSSLPaths()
|
||||||
|
case SSLModeLetsEncrypt:
|
||||||
|
if strings.TrimSpace(ssl.Target) == "" && ssl.CertPath != "" && ssl.KeyPath != "" {
|
||||||
|
return ResolveSSLPathPair(ssl.CertPath, ssl.KeyPath)
|
||||||
|
}
|
||||||
|
return LetsEncryptSSLPaths(ssl.Target)
|
||||||
|
default:
|
||||||
|
return "", "", fmt.Errorf("SSL is disabled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ResolveSSLPathPair(certPath, keyPath string) (string, string, error) {
|
||||||
|
safeCertPath, err := ResolveSSLPath(certPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
safeKeyPath, err := ResolveSSLPath(keyPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
return safeCertPath, safeKeyPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ResolveSSLPath(path string) (string, error) {
|
||||||
|
cleaned, err := cleanAbsolutePath(path)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if isPathUnder(cleaned, SSLStorageDir()) || isPathUnder(cleaned, letsEncryptLiveDir) || isPathUnder(cleaned, "/etc/letsencrypt/archive") {
|
||||||
|
return cleaned, nil
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("SSL path is outside allowed certificate directories")
|
||||||
|
}
|
||||||
|
|
||||||
|
func ReadableFileStat(path string) (os.FileInfo, error) {
|
||||||
|
safePath, err := ResolveSSLPath(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return os.Stat(safePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NormalizeSSLCertificateTarget(target string) (string, error) {
|
||||||
|
target = strings.TrimSpace(strings.Trim(target, "[]"))
|
||||||
|
if target == "" {
|
||||||
|
return "", fmt.Errorf("SSL target is required")
|
||||||
|
}
|
||||||
|
if strings.Contains(target, "/") || strings.Contains(target, "\\") || strings.Contains(target, "..") {
|
||||||
|
return "", fmt.Errorf("SSL target contains invalid path characters")
|
||||||
|
}
|
||||||
|
if ip := net.ParseIP(target); ip != nil {
|
||||||
|
return ip.String(), nil
|
||||||
|
}
|
||||||
|
if len(target) > 253 || !dnsNamePattern.MatchString(target) {
|
||||||
|
return "", fmt.Errorf("SSL target must be a valid IP address or DNS name")
|
||||||
|
}
|
||||||
|
labels := strings.Split(target, ".")
|
||||||
|
for _, label := range labels {
|
||||||
|
if label == "" || len(label) > 63 || strings.HasPrefix(label, "-") || strings.HasSuffix(label, "-") {
|
||||||
|
return "", fmt.Errorf("SSL target must be a valid IP address or DNS name")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.ToLower(target), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func safeSSLStorageDir() (string, error) {
|
||||||
|
dir, err := cleanAbsolutePath(SSLStorageDir())
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
dataDir := ""
|
||||||
|
if AppConfig != nil {
|
||||||
|
dataDir = AppConfig.DataDir
|
||||||
|
}
|
||||||
|
if dataDir == "" {
|
||||||
|
dataDir = getDataDir()
|
||||||
|
}
|
||||||
|
if !isPathUnder(dir, dataDir) {
|
||||||
|
return "", fmt.Errorf("SSL storage directory is outside the data directory")
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return dir, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func cleanAbsolutePath(path string) (string, error) {
|
||||||
|
if strings.TrimSpace(path) == "" {
|
||||||
|
return "", fmt.Errorf("path is empty")
|
||||||
|
}
|
||||||
|
abs, err := filepath.Abs(path)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return filepath.Clean(abs), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isPathUnder(path, root string) bool {
|
||||||
|
cleanPath, err := cleanAbsolutePath(path)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
cleanRoot, err := cleanAbsolutePath(root)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
rel, err := filepath.Rel(cleanRoot, cleanPath)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)))
|
||||||
|
}
|
||||||
@@ -20,24 +20,33 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type savedTaskConfig struct {
|
type savedTaskConfig struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Virtualization string `json:"virtualization,omitempty"`
|
Virtualization string `json:"virtualization,omitempty"`
|
||||||
TemplateID string `json:"template_id"`
|
TemplateID string `json:"template_id"`
|
||||||
VCPU float64 `json:"vcpu"`
|
VCPU float64 `json:"vcpu"`
|
||||||
CPUPercent int `json:"cpu_percent"`
|
CPUPercent int `json:"cpu_percent"`
|
||||||
RAMMB int `json:"ram_mb"`
|
RAMMB int `json:"ram_mb"`
|
||||||
DiskGB int `json:"disk_gb"`
|
DiskGB int `json:"disk_gb"`
|
||||||
NetworkBWMbps int `json:"network_bw_mbps"`
|
NetworkBWMbps int `json:"network_bw_mbps"`
|
||||||
MonthlyTrafficGB int `json:"monthly_traffic_gb"`
|
MonthlyTrafficGB int `json:"monthly_traffic_gb"`
|
||||||
TrafficMode string `json:"traffic_mode"`
|
TrafficMode string `json:"traffic_mode"`
|
||||||
TrafficInGB int `json:"traffic_in_gb"`
|
TrafficInGB int `json:"traffic_in_gb"`
|
||||||
TrafficOutGB int `json:"traffic_out_gb"`
|
TrafficOutGB int `json:"traffic_out_gb"`
|
||||||
IOSpeedMBps int `json:"io_speed_mbps"`
|
IOSpeedMBps int `json:"io_speed_mbps"`
|
||||||
ExtraPorts []int `json:"extra_ports"`
|
ExtraPorts []int `json:"extra_ports"`
|
||||||
PortMappingCount int `json:"port_mapping_count"`
|
PortMappingCount int `json:"port_mapping_count"`
|
||||||
SnapshotLimit int `json:"snapshot_limit"`
|
AssignNAT *bool `json:"assign_nat,omitempty"`
|
||||||
AssignIPv6 bool `json:"assign_ipv6"`
|
SnapshotLimit int `json:"snapshot_limit"`
|
||||||
ExpiresAt string `json:"expires_at"`
|
AssignIPv4 bool `json:"assign_ipv4"`
|
||||||
|
IPv4Count int `json:"ipv4_count,omitempty"`
|
||||||
|
PublicIPv4s []string `json:"public_ipv4s,omitempty"`
|
||||||
|
AssignIPv6 bool `json:"assign_ipv6"`
|
||||||
|
IPv6Count int `json:"ipv6_count,omitempty"`
|
||||||
|
IPv6Addresses []string `json:"ipv6_addresses,omitempty"`
|
||||||
|
SSHAuthMode string `json:"ssh_auth_mode,omitempty"`
|
||||||
|
SSHPassword string `json:"ssh_password,omitempty"`
|
||||||
|
SSHPublicKey string `json:"ssh_public_key,omitempty"`
|
||||||
|
ExpiresAt string `json:"expires_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseSavedTaskConfig(raw string) savedTaskConfig {
|
func parseSavedTaskConfig(raw string) savedTaskConfig {
|
||||||
@@ -57,6 +66,28 @@ func encodeSavedTaskConfig(cfg savedTaskConfig) string {
|
|||||||
return string(data)
|
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 {
|
func getDBPath() string {
|
||||||
cfgPath := getConfigPath()
|
cfgPath := getConfigPath()
|
||||||
ext := filepath.Ext(cfgPath)
|
ext := filepath.Ext(cfgPath)
|
||||||
@@ -153,10 +184,28 @@ func ensureSchema() error {
|
|||||||
position INTEGER NOT NULL,
|
position INTEGER NOT NULL,
|
||||||
container_port INTEGER NOT NULL,
|
container_port INTEGER NOT NULL,
|
||||||
host_port INTEGER NOT NULL,
|
host_port INTEGER NOT NULL,
|
||||||
|
host_ip TEXT,
|
||||||
protocol TEXT,
|
protocol TEXT,
|
||||||
description TEXT,
|
description TEXT,
|
||||||
PRIMARY KEY (container_id, position)
|
PRIMARY KEY (container_id, position)
|
||||||
)`,
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS container_public_ipv4s (
|
||||||
|
container_id INTEGER NOT NULL,
|
||||||
|
position INTEGER NOT NULL,
|
||||||
|
address TEXT NOT NULL,
|
||||||
|
interface TEXT,
|
||||||
|
prefix_len INTEGER,
|
||||||
|
gateway TEXT,
|
||||||
|
PRIMARY KEY (container_id, position)
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS container_ipv6_addresses (
|
||||||
|
container_id INTEGER NOT NULL,
|
||||||
|
position INTEGER NOT NULL,
|
||||||
|
address TEXT NOT NULL,
|
||||||
|
prefix_len INTEGER,
|
||||||
|
interface TEXT,
|
||||||
|
PRIMARY KEY (container_id, position)
|
||||||
|
)`,
|
||||||
`CREATE TABLE IF NOT EXISTS sub_users (
|
`CREATE TABLE IF NOT EXISTS sub_users (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
username TEXT NOT NULL,
|
username TEXT NOT NULL,
|
||||||
@@ -185,7 +234,12 @@ func ensureSchema() error {
|
|||||||
prefix TEXT,
|
prefix TEXT,
|
||||||
ip_whitelist TEXT,
|
ip_whitelist TEXT,
|
||||||
created_at 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 (
|
`CREATE TABLE IF NOT EXISTS audit_logs (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
@@ -210,6 +264,8 @@ func ensureSchema() error {
|
|||||||
created_at TEXT,
|
created_at TEXT,
|
||||||
template_id TEXT,
|
template_id TEXT,
|
||||||
user TEXT,
|
user TEXT,
|
||||||
|
ip TEXT,
|
||||||
|
user_agent TEXT,
|
||||||
cfg_name TEXT,
|
cfg_name TEXT,
|
||||||
cfg_virtualization TEXT,
|
cfg_virtualization TEXT,
|
||||||
cfg_template_id TEXT,
|
cfg_template_id TEXT,
|
||||||
@@ -224,8 +280,17 @@ func ensureSchema() error {
|
|||||||
cfg_traffic_out_gb INTEGER,
|
cfg_traffic_out_gb INTEGER,
|
||||||
cfg_io_speed_mbps INTEGER,
|
cfg_io_speed_mbps INTEGER,
|
||||||
cfg_port_mapping_count INTEGER,
|
cfg_port_mapping_count INTEGER,
|
||||||
|
cfg_assign_nat INTEGER,
|
||||||
cfg_snapshot_limit INTEGER,
|
cfg_snapshot_limit INTEGER,
|
||||||
|
cfg_assign_ipv4 INTEGER,
|
||||||
|
cfg_ipv4_count INTEGER,
|
||||||
|
cfg_public_ipv4s TEXT,
|
||||||
cfg_assign_ipv6 INTEGER,
|
cfg_assign_ipv6 INTEGER,
|
||||||
|
cfg_ipv6_count INTEGER,
|
||||||
|
cfg_ipv6_addresses TEXT,
|
||||||
|
cfg_ssh_auth_mode TEXT,
|
||||||
|
cfg_ssh_password TEXT,
|
||||||
|
cfg_ssh_public_key TEXT,
|
||||||
cfg_expires_at TEXT
|
cfg_expires_at TEXT
|
||||||
)`,
|
)`,
|
||||||
`CREATE TABLE IF NOT EXISTS task_extra_ports (
|
`CREATE TABLE IF NOT EXISTS task_extra_ports (
|
||||||
@@ -263,9 +328,67 @@ func ensureSchema() error {
|
|||||||
return fmt.Errorf("failed to create sqlite schema: %v", err)
|
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"},
|
||||||
|
{"tasks", "cfg_assign_ipv4", "INTEGER"},
|
||||||
|
{"tasks", "cfg_ipv4_count", "INTEGER"},
|
||||||
|
{"tasks", "cfg_public_ipv4s", "TEXT"},
|
||||||
|
{"tasks", "cfg_assign_nat", "INTEGER"},
|
||||||
|
{"tasks", "cfg_ipv6_count", "INTEGER"},
|
||||||
|
{"tasks", "cfg_ipv6_addresses", "TEXT"},
|
||||||
|
{"tasks", "cfg_ssh_auth_mode", "TEXT"},
|
||||||
|
{"tasks", "cfg_ssh_password", "TEXT"},
|
||||||
|
{"tasks", "cfg_ssh_public_key", "TEXT"},
|
||||||
|
{"port_mappings", "host_ip", "TEXT"},
|
||||||
|
{"container_public_ipv4s", "prefix_len", "INTEGER"},
|
||||||
|
{"container_public_ipv4s", "gateway", "TEXT"},
|
||||||
|
} {
|
||||||
|
if err := ensureColumn(column.table, column.name, column.def); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil
|
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) {
|
func loadConfigFromDB() (*ClicdConfig, bool, error) {
|
||||||
meta := map[string]string{}
|
meta := map[string]string{}
|
||||||
rows, err := db.Query("SELECT key, value FROM app_meta")
|
rows, err := db.Query("SELECT key, value FROM app_meta")
|
||||||
@@ -298,6 +421,22 @@ func loadConfigFromDB() (*ClicdConfig, bool, error) {
|
|||||||
NextSSHPort: atoi(meta["next_ssh_port"]),
|
NextSSHPort: atoi(meta["next_ssh_port"]),
|
||||||
SetupComplete: atob(meta["setup_complete"]),
|
SetupComplete: atob(meta["setup_complete"]),
|
||||||
SecurityAutoShutdown: atob(meta["security_auto_shutdown"]),
|
SecurityAutoShutdown: atob(meta["security_auto_shutdown"]),
|
||||||
|
Language: meta["language"],
|
||||||
|
}
|
||||||
|
if raw := strings.TrimSpace(meta["ssl"]); raw != "" {
|
||||||
|
_ = json.Unmarshal([]byte(raw), &cfg.SSL)
|
||||||
|
}
|
||||||
|
if raw := strings.TrimSpace(meta["ssl_certificates"]); raw != "" {
|
||||||
|
_ = json.Unmarshal([]byte(raw), &cfg.SSLCertificates)
|
||||||
|
}
|
||||||
|
if raw := strings.TrimSpace(meta["public_ipv4_pool"]); raw != "" {
|
||||||
|
_ = json.Unmarshal([]byte(raw), &cfg.PublicIPv4Pool)
|
||||||
|
}
|
||||||
|
if raw := strings.TrimSpace(meta["public_ipv6_prefixes"]); raw != "" {
|
||||||
|
_ = json.Unmarshal([]byte(raw), &cfg.PublicIPv6Prefixes)
|
||||||
|
}
|
||||||
|
if raw := strings.TrimSpace(meta["webssh_allowed_origins"]); raw != "" {
|
||||||
|
_ = json.Unmarshal([]byte(raw), &cfg.WebSSHAllowedOrigins)
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.Containers, err = loadContainers(); err != nil {
|
if cfg.Containers, err = loadContainers(); err != nil {
|
||||||
@@ -342,6 +481,8 @@ func saveConfigToDB() error {
|
|||||||
|
|
||||||
for _, table := range []string{
|
for _, table := range []string{
|
||||||
"port_mappings",
|
"port_mappings",
|
||||||
|
"container_public_ipv4s",
|
||||||
|
"container_ipv6_addresses",
|
||||||
"sub_user_container_names",
|
"sub_user_container_names",
|
||||||
"sub_user_container_uuids",
|
"sub_user_container_uuids",
|
||||||
"containers",
|
"containers",
|
||||||
@@ -391,6 +532,11 @@ func saveConfigToDB() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func saveMeta(tx *sql.Tx) error {
|
func saveMeta(tx *sql.Tx) error {
|
||||||
|
sslJSON, _ := json.Marshal(AppConfig.SSL)
|
||||||
|
sslCertificatesJSON, _ := json.Marshal(AppConfig.SSLCertificates)
|
||||||
|
publicIPv4PoolJSON, _ := json.Marshal(AppConfig.PublicIPv4Pool)
|
||||||
|
publicIPv6PrefixesJSON, _ := json.Marshal(AppConfig.PublicIPv6Prefixes)
|
||||||
|
webSSHAllowedOriginsJSON, _ := json.Marshal(AppConfig.WebSSHAllowedOrigins)
|
||||||
values := map[string]string{
|
values := map[string]string{
|
||||||
"admin_user": AppConfig.AdminUser,
|
"admin_user": AppConfig.AdminUser,
|
||||||
"admin_pass_hash": AppConfig.AdminPassHash,
|
"admin_pass_hash": AppConfig.AdminPassHash,
|
||||||
@@ -402,6 +548,12 @@ func saveMeta(tx *sql.Tx) error {
|
|||||||
"next_ssh_port": strconv.Itoa(AppConfig.NextSSHPort),
|
"next_ssh_port": strconv.Itoa(AppConfig.NextSSHPort),
|
||||||
"setup_complete": btoa(AppConfig.SetupComplete),
|
"setup_complete": btoa(AppConfig.SetupComplete),
|
||||||
"security_auto_shutdown": btoa(AppConfig.SecurityAutoShutdown),
|
"security_auto_shutdown": btoa(AppConfig.SecurityAutoShutdown),
|
||||||
|
"language": NormalizeLanguage(AppConfig.Language),
|
||||||
|
"ssl": string(sslJSON),
|
||||||
|
"ssl_certificates": string(sslCertificatesJSON),
|
||||||
|
"public_ipv4_pool": string(publicIPv4PoolJSON),
|
||||||
|
"public_ipv6_prefixes": string(publicIPv6PrefixesJSON),
|
||||||
|
"webssh_allowed_origins": string(webSSHAllowedOriginsJSON),
|
||||||
"schema_version": "1",
|
"schema_version": "1",
|
||||||
"updated_at": time.Now().Format("2006-01-02 15:04:05"),
|
"updated_at": time.Now().Format("2006-01-02 15:04:05"),
|
||||||
}
|
}
|
||||||
@@ -437,8 +589,20 @@ func saveContainers(tx *sql.Tx) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
for i, pm := range c.PortMappings {
|
for i, pm := range c.PortMappings {
|
||||||
if _, err := tx.Exec(`INSERT INTO port_mappings(container_id, position, container_port, host_port, protocol, description)
|
if _, err := tx.Exec(`INSERT INTO port_mappings(container_id, position, container_port, host_port, host_ip, protocol, description)
|
||||||
VALUES (?, ?, ?, ?, ?, ?)`, c.ID, i, pm.ContainerPort, pm.HostPort, pm.Protocol, pm.Description); err != nil {
|
VALUES (?, ?, ?, ?, ?, ?, ?)`, c.ID, i, pm.ContainerPort, pm.HostPort, pm.HostIP, pm.Protocol, pm.Description); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i, ip := range c.PublicIPv4s {
|
||||||
|
if _, err := tx.Exec(`INSERT INTO container_public_ipv4s(container_id, position, address, interface, prefix_len, gateway)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)`, c.ID, i, ip.Address, ip.Interface, ip.PrefixLen, ip.Gateway); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i, ip := range c.IPv6Addresses {
|
||||||
|
if _, err := tx.Exec(`INSERT INTO container_ipv6_addresses(container_id, position, address, prefix_len, interface)
|
||||||
|
VALUES (?, ?, ?, ?, ?)`, c.ID, i, ip.Address, ip.PrefixLen, ip.Interface); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -468,8 +632,10 @@ func saveSubUsers(tx *sql.Tx) error {
|
|||||||
|
|
||||||
func saveAPIKeys(tx *sql.Tx) error {
|
func saveAPIKeys(tx *sql.Tx) error {
|
||||||
for _, k := range AppConfig.ApiKeys {
|
for _, k := range AppConfig.ApiKeys {
|
||||||
if _, err := tx.Exec(`INSERT INTO api_keys(id, name, key_hash, prefix, ip_whitelist, created_at, last_used)
|
scopes := encodeStringSlice(k.Scopes)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)`, k.ID, k.Name, k.KeyHash, k.Prefix, k.IPWhitelist, k.CreatedAt, k.LastUsed); err != nil {
|
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
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -498,17 +664,20 @@ func saveTasksDB(tx *sql.Tx) error {
|
|||||||
for _, task := range AppConfig.Tasks {
|
for _, task := range AppConfig.Tasks {
|
||||||
cfg := parseSavedTaskConfig(task.Config)
|
cfg := parseSavedTaskConfig(task.Config)
|
||||||
if _, err := tx.Exec(`INSERT INTO tasks(
|
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_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_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_traffic_out_gb, cfg_io_speed_mbps, cfg_port_mapping_count, cfg_assign_nat, cfg_snapshot_limit,
|
||||||
cfg_assign_ipv6, cfg_expires_at
|
cfg_assign_ipv4, cfg_ipv4_count, cfg_public_ipv4s, cfg_assign_ipv6, cfg_ipv6_count, cfg_ipv6_addresses,
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
cfg_ssh_auth_mode, cfg_ssh_password, cfg_ssh_public_key, cfg_expires_at
|
||||||
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.Name, cfg.Virtualization, cfg.TemplateID, cfg.VCPU, cfg.CPUPercent, cfg.RAMMB, cfg.DiskGB,
|
||||||
cfg.NetworkBWMbps, cfg.MonthlyTrafficGB, cfg.TrafficMode, cfg.TrafficInGB,
|
cfg.NetworkBWMbps, cfg.MonthlyTrafficGB, cfg.TrafficMode, cfg.TrafficInGB,
|
||||||
cfg.TrafficOutGB, cfg.IOSpeedMBps, cfg.PortMappingCount, cfg.SnapshotLimit,
|
cfg.TrafficOutGB, cfg.IOSpeedMBps, cfg.PortMappingCount, boolPtrInt(cfg.AssignNAT), cfg.SnapshotLimit,
|
||||||
boolInt(cfg.AssignIPv6), cfg.ExpiresAt,
|
boolInt(cfg.AssignIPv4), cfg.IPv4Count, encodeStringSlice(cfg.PublicIPv4s),
|
||||||
|
boolInt(cfg.AssignIPv6), cfg.IPv6Count, encodeStringSlice(cfg.IPv6Addresses),
|
||||||
|
cfg.SSHAuthMode, cfg.SSHPassword, cfg.SSHPublicKey, cfg.ExpiresAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -597,12 +766,21 @@ func loadContainers() ([]Container, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
result[i].PublicIPv4s, err = loadContainerPublicIPv4s(result[i].ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result[i].IPv6Addresses, err = loadContainerIPv6Addresses(result[i].ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result[i].NormalizeNetworkAssignments()
|
||||||
}
|
}
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadPortMappings(containerID int) ([]PortMapping, error) {
|
func loadPortMappings(containerID int) ([]PortMapping, error) {
|
||||||
rows, err := db.Query(`SELECT container_port, host_port, protocol, description FROM port_mappings WHERE container_id = ? ORDER BY position`, containerID)
|
rows, err := db.Query(`SELECT container_port, host_port, host_ip, protocol, description FROM port_mappings WHERE container_id = ? ORDER BY position`, containerID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -610,14 +788,64 @@ func loadPortMappings(containerID int) ([]PortMapping, error) {
|
|||||||
result := []PortMapping{}
|
result := []PortMapping{}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var pm PortMapping
|
var pm PortMapping
|
||||||
if err := rows.Scan(&pm.ContainerPort, &pm.HostPort, &pm.Protocol, &pm.Description); err != nil {
|
var hostIP sql.NullString
|
||||||
|
if err := rows.Scan(&pm.ContainerPort, &pm.HostPort, &hostIP, &pm.Protocol, &pm.Description); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
pm.HostIP = hostIP.String
|
||||||
result = append(result, pm)
|
result = append(result, pm)
|
||||||
}
|
}
|
||||||
return result, rows.Err()
|
return result, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func loadContainerPublicIPv4s(containerID int) ([]PublicIPv4Assignment, error) {
|
||||||
|
rows, err := db.Query(`SELECT address, interface, prefix_len, gateway FROM container_public_ipv4s WHERE container_id = ? ORDER BY position`, containerID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
result := []PublicIPv4Assignment{}
|
||||||
|
for rows.Next() {
|
||||||
|
var item PublicIPv4Assignment
|
||||||
|
var iface sql.NullString
|
||||||
|
var prefixLen sql.NullInt64
|
||||||
|
var gateway sql.NullString
|
||||||
|
if err := rows.Scan(&item.Address, &iface, &prefixLen, &gateway); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
item.Interface = iface.String
|
||||||
|
if prefixLen.Valid {
|
||||||
|
item.PrefixLen = int(prefixLen.Int64)
|
||||||
|
}
|
||||||
|
item.Gateway = gateway.String
|
||||||
|
result = append(result, item)
|
||||||
|
}
|
||||||
|
return result, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadContainerIPv6Addresses(containerID int) ([]IPv6Assignment, error) {
|
||||||
|
rows, err := db.Query(`SELECT address, prefix_len, interface FROM container_ipv6_addresses WHERE container_id = ? ORDER BY position`, containerID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
result := []IPv6Assignment{}
|
||||||
|
for rows.Next() {
|
||||||
|
var item IPv6Assignment
|
||||||
|
var prefixLen sql.NullInt64
|
||||||
|
var iface sql.NullString
|
||||||
|
if err := rows.Scan(&item.Address, &prefixLen, &iface); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if prefixLen.Valid {
|
||||||
|
item.PrefixLen = int(prefixLen.Int64)
|
||||||
|
}
|
||||||
|
item.Interface = iface.String
|
||||||
|
result = append(result, item)
|
||||||
|
}
|
||||||
|
return result, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
func loadSubUsers() ([]SubUser, error) {
|
func loadSubUsers() ([]SubUser, error) {
|
||||||
rows, err := db.Query(`SELECT id, username, password, pass_hash, access_code, created_at, token_version FROM sub_users ORDER BY created_at, id`)
|
rows, err := db.Query(`SELECT id, username, password, pass_hash, access_code, created_at, token_version FROM sub_users ORDER BY created_at, id`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -669,7 +897,7 @@ func loadStringList(table, valueColumn, keyColumn, key string) ([]string, error)
|
|||||||
}
|
}
|
||||||
|
|
||||||
func loadAPIKeys() ([]ApiKeyConfig, 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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -677,9 +905,16 @@ func loadAPIKeys() ([]ApiKeyConfig, error) {
|
|||||||
result := []ApiKeyConfig{}
|
result := []ApiKeyConfig{}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var k ApiKeyConfig
|
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
|
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)
|
result = append(result, k)
|
||||||
}
|
}
|
||||||
return result, rows.Err()
|
return result, rows.Err()
|
||||||
@@ -709,11 +944,12 @@ func loadAuditLogs() ([]AuditLog, error) {
|
|||||||
|
|
||||||
func loadTasks() ([]SavedTask, error) {
|
func loadTasks() ([]SavedTask, error) {
|
||||||
rows, err := db.Query(`SELECT
|
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_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_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_traffic_out_gb, cfg_io_speed_mbps, cfg_port_mapping_count, cfg_assign_nat, cfg_snapshot_limit,
|
||||||
cfg_assign_ipv6, cfg_expires_at
|
cfg_assign_ipv4, cfg_ipv4_count, cfg_public_ipv4s, cfg_assign_ipv6, cfg_ipv6_count, cfg_ipv6_addresses,
|
||||||
|
cfg_ssh_auth_mode, cfg_ssh_password, cfg_ssh_public_key, cfg_expires_at
|
||||||
FROM tasks ORDER BY created_at, id`)
|
FROM tasks ORDER BY created_at, id`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -724,17 +960,39 @@ func loadTasks() ([]SavedTask, error) {
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var t SavedTask
|
var t SavedTask
|
||||||
var cfg savedTaskConfig
|
var cfg savedTaskConfig
|
||||||
var assignIPv6 int
|
var assignIPv4, assignIPv6 int
|
||||||
|
var ip, userAgent, publicIPv4s, ipv6Addresses sql.NullString
|
||||||
|
var sshAuthMode, sshPassword, sshPublicKey sql.NullString
|
||||||
|
var assignNAT, ipv4Count, ipv6Count sql.NullInt64
|
||||||
if err := rows.Scan(
|
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.Name, &cfg.Virtualization, &cfg.TemplateID, &cfg.VCPU, &cfg.CPUPercent, &cfg.RAMMB, &cfg.DiskGB,
|
||||||
&cfg.NetworkBWMbps, &cfg.MonthlyTrafficGB, &cfg.TrafficMode, &cfg.TrafficInGB,
|
&cfg.NetworkBWMbps, &cfg.MonthlyTrafficGB, &cfg.TrafficMode, &cfg.TrafficInGB,
|
||||||
&cfg.TrafficOutGB, &cfg.IOSpeedMBps, &cfg.PortMappingCount, &cfg.SnapshotLimit,
|
&cfg.TrafficOutGB, &cfg.IOSpeedMBps, &cfg.PortMappingCount, &assignNAT, &cfg.SnapshotLimit,
|
||||||
&assignIPv6, &cfg.ExpiresAt,
|
&assignIPv4, &ipv4Count, &publicIPv4s, &assignIPv6, &ipv6Count, &ipv6Addresses,
|
||||||
|
&sshAuthMode, &sshPassword, &sshPublicKey, &cfg.ExpiresAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
t.IP = ip.String
|
||||||
|
t.UserAgent = userAgent.String
|
||||||
|
if assignNAT.Valid {
|
||||||
|
value := assignNAT.Int64 != 0
|
||||||
|
cfg.AssignNAT = &value
|
||||||
|
}
|
||||||
|
cfg.AssignIPv4 = assignIPv4 != 0
|
||||||
|
if ipv4Count.Valid {
|
||||||
|
cfg.IPv4Count = int(ipv4Count.Int64)
|
||||||
|
}
|
||||||
|
cfg.PublicIPv4s = decodeStringSlice(publicIPv4s.String)
|
||||||
cfg.AssignIPv6 = assignIPv6 != 0
|
cfg.AssignIPv6 = assignIPv6 != 0
|
||||||
|
if ipv6Count.Valid {
|
||||||
|
cfg.IPv6Count = int(ipv6Count.Int64)
|
||||||
|
}
|
||||||
|
cfg.IPv6Addresses = decodeStringSlice(ipv6Addresses.String)
|
||||||
|
cfg.SSHAuthMode = sshAuthMode.String
|
||||||
|
cfg.SSHPassword = sshPassword.String
|
||||||
|
cfg.SSHPublicKey = sshPublicKey.String
|
||||||
result = append(result, t)
|
result = append(result, t)
|
||||||
configs = append(configs, cfg)
|
configs = append(configs, cfg)
|
||||||
}
|
}
|
||||||
@@ -848,6 +1106,13 @@ func boolInt(value bool) int {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func boolPtrInt(value *bool) interface{} {
|
||||||
|
if value == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return boolInt(*value)
|
||||||
|
}
|
||||||
|
|
||||||
func btoa(value bool) string {
|
func btoa(value bool) string {
|
||||||
if value {
|
if value {
|
||||||
return "1"
|
return "1"
|
||||||
|
|||||||
+320
-92
@@ -37,6 +37,7 @@ type Manager struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ipv6GatewayLinkLocal = "fe80::1"
|
const ipv6GatewayLinkLocal = "fe80::1"
|
||||||
|
const libvirtDefaultNetworkMarker = "/var/lib/clicd/kvm/default-network.created"
|
||||||
|
|
||||||
type usageSample struct {
|
type usageSample struct {
|
||||||
CPUUsec uint64
|
CPUUsec uint64
|
||||||
@@ -363,8 +364,11 @@ func (m *Manager) CreateContainer(cfg lxc.ContainerConfig) error {
|
|||||||
if cfg.VCPU < 1 || cfg.VCPU != float64(int(cfg.VCPU)) {
|
if cfg.VCPU < 1 || cfg.VCPU != float64(int(cfg.VCPU)) {
|
||||||
return fmt.Errorf("KVM vCPU must be a whole number and at least 1")
|
return fmt.Errorf("KVM vCPU must be a whole number and at least 1")
|
||||||
}
|
}
|
||||||
if cfg.PortMappingCount < 2 {
|
if cfg.WantsNAT() && cfg.PortMappingCount < 2 {
|
||||||
cfg.PortMappingCount = 2
|
cfg.PortMappingCount = 2
|
||||||
|
} else if !cfg.WantsNAT() {
|
||||||
|
cfg.PortMappingCount = 0
|
||||||
|
cfg.ExtraPorts = nil
|
||||||
}
|
}
|
||||||
if cfg.SnapshotLimit <= 0 {
|
if cfg.SnapshotLimit <= 0 {
|
||||||
cfg.SnapshotLimit = config.DefaultSnapshotLimit
|
cfg.SnapshotLimit = config.DefaultSnapshotLimit
|
||||||
@@ -402,18 +406,30 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig
|
|||||||
seedPath := filepath.Join(m.instanceDir(vmName), "seed.iso")
|
seedPath := filepath.Join(m.instanceDir(vmName), "seed.iso")
|
||||||
mac := randomMAC()
|
mac := randomMAC()
|
||||||
sshPassword := generateRandomString(16)
|
sshPassword := generateRandomString(16)
|
||||||
ipv6 := ""
|
sshPublicKey := ""
|
||||||
ipv6PrefixLen := 0
|
if !IsWindowsImage(image.ID) {
|
||||||
ipv6Interface := ""
|
sshAccess, err := lxc.ResolveCreateSSHAccess(cfg)
|
||||||
if cfg.AssignIPv6 {
|
|
||||||
assigned, prefixLen, iface, err := m.allocateIPv6ForContainer(id)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
ipv6 = assigned
|
sshPassword = sshAccess.Password
|
||||||
ipv6PrefixLen = prefixLen
|
sshPublicKey = sshAccess.PublicKey
|
||||||
ipv6Interface = iface
|
|
||||||
}
|
}
|
||||||
|
publicIPv4s, err := lxc.AllocatePublicIPv4Assignments(id, cfg.PublicIPv4s, cfg.IPv4Count, cfg.AssignIPv4)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
ipv6Assignments := []config.IPv6Assignment{}
|
||||||
|
if cfg.AssignIPv6 || len(cfg.IPv6Addresses) > 0 {
|
||||||
|
assigned, err := m.allocateIPv6AssignmentsForContainer(id, cfg.IPv6Addresses, cfg.IPv6Count, true)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ipv6Assignments = assigned
|
||||||
|
}
|
||||||
|
ipv6List := configIPv6AssignmentAddresses(ipv6Assignments)
|
||||||
|
defaultHostIP := lxc.DefaultPortMappingHostIP(publicIPv4s)
|
||||||
|
|
||||||
var xml string
|
var xml string
|
||||||
winAdminPassword := ""
|
winAdminPassword := ""
|
||||||
@@ -432,7 +448,7 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig
|
|||||||
}
|
}
|
||||||
winAdminPassword = generateWindowsPassword()
|
winAdminPassword = generateWindowsPassword()
|
||||||
unattendPath := filepath.Join(m.instanceDir(vmName), "unattend.iso")
|
unattendPath := filepath.Join(m.instanceDir(vmName), "unattend.iso")
|
||||||
if err := createWindowsUnattendISO(unattendPath, cfg.Name, winAdminPassword, ipv6); err != nil {
|
if err := createWindowsUnattendISO(unattendPath, cfg.Name, winAdminPassword, ipv6List); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
xml = windowsDomainXML(vmName, int(cfg.VCPU), cfg.RAMMB, diskPath, ImagePath(image.ID), unattendPath, mac, cfg.IOSpeedMBps, cfg.NetworkBWMbps)
|
xml = windowsDomainXML(vmName, int(cfg.VCPU), cfg.RAMMB, diskPath, ImagePath(image.ID), unattendPath, mac, cfg.IOSpeedMBps, cfg.NetworkBWMbps)
|
||||||
@@ -448,7 +464,7 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig
|
|||||||
if err := createOverlayDisk(ImagePath(image.ID), diskPath, cfg.DiskGB); err != nil {
|
if err := createOverlayDisk(ImagePath(image.ID), diskPath, cfg.DiskGB); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := createSeedISO(seedPath, vmName, cfg.Name, sshPassword, mac, ipv6, *image); err != nil {
|
if err := createSeedISO(seedPath, vmName, cfg.Name, sshPassword, sshPublicKey, mac, ipv6List, *image); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
xml = domainXML(vmName, int(cfg.VCPU), cfg.RAMMB, diskPath, seedPath, mac, cfg.IOSpeedMBps, cfg.NetworkBWMbps, image.Desktop != "")
|
xml = domainXML(vmName, int(cfg.VCPU), cfg.RAMMB, diskPath, seedPath, mac, cfg.IOSpeedMBps, cfg.NetworkBWMbps, image.Desktop != "")
|
||||||
@@ -464,20 +480,26 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig
|
|||||||
|
|
||||||
sshPort := 0
|
sshPort := 0
|
||||||
portMappings := []config.PortMapping{}
|
portMappings := []config.PortMapping{}
|
||||||
if allocatePorts {
|
if allocatePorts && cfg.WantsNAT() {
|
||||||
sshPort = config.AllocateSSHPort()
|
sshPort = config.AllocateSSHPort()
|
||||||
if IsWindowsImage(image.ID) {
|
if IsWindowsImage(image.ID) {
|
||||||
// Windows: RDP (3389) instead of SSH (22)
|
// Windows: RDP (3389) instead of SSH (22)
|
||||||
portMappings = []config.PortMapping{{
|
portMappings = []config.PortMapping{{
|
||||||
ContainerPort: 3389,
|
ContainerPort: 3389,
|
||||||
HostPort: sshPort,
|
HostPort: sshPort,
|
||||||
|
HostIP: defaultHostIP,
|
||||||
Protocol: "tcp",
|
Protocol: "tcp",
|
||||||
Description: "RDP",
|
Description: "RDP",
|
||||||
}}
|
}}
|
||||||
} else {
|
} else {
|
||||||
portMappings = lxc.SetupDefaultPortMappings(sshPort)
|
portMappings = lxc.SetupDefaultPortMappings(sshPort)
|
||||||
|
if defaultHostIP != "" {
|
||||||
|
for i := range portMappings {
|
||||||
|
portMappings[i].HostIP = defaultHostIP
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
tempC := &config.Container{PortMappings: portMappings}
|
tempC := &config.Container{ID: id, PublicIPv4s: publicIPv4s, PortMappings: portMappings}
|
||||||
extraPorts := cfg.ExtraPorts
|
extraPorts := cfg.ExtraPorts
|
||||||
if len(extraPorts) == 0 && cfg.PortMappingCount > 1 {
|
if len(extraPorts) == 0 && cfg.PortMappingCount > 1 {
|
||||||
extraPorts = allocateDefaultEqualPorts(tempC, cfg.PortMappingCount-1)
|
extraPorts = allocateDefaultEqualPorts(tempC, cfg.PortMappingCount-1)
|
||||||
@@ -489,6 +511,7 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig
|
|||||||
tempC.PortMappings = append(tempC.PortMappings, config.PortMapping{
|
tempC.PortMappings = append(tempC.PortMappings, config.PortMapping{
|
||||||
ContainerPort: port,
|
ContainerPort: port,
|
||||||
HostPort: port,
|
HostPort: port,
|
||||||
|
HostIP: defaultHostIP,
|
||||||
Protocol: "tcp",
|
Protocol: "tcp",
|
||||||
Description: fmt.Sprintf("Port-%d", port),
|
Description: fmt.Sprintf("Port-%d", port),
|
||||||
})
|
})
|
||||||
@@ -501,7 +524,7 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig
|
|||||||
if trafficMode == "" {
|
if trafficMode == "" {
|
||||||
trafficMode = "total"
|
trafficMode = "total"
|
||||||
}
|
}
|
||||||
return &config.Container{
|
container := &config.Container{
|
||||||
ID: id,
|
ID: id,
|
||||||
UUID: config.NewContainerUUID(),
|
UUID: config.NewContainerUUID(),
|
||||||
Name: cfg.Name,
|
Name: cfg.Name,
|
||||||
@@ -520,9 +543,8 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig
|
|||||||
TrafficOutGB: cfg.TrafficOutGB,
|
TrafficOutGB: cfg.TrafficOutGB,
|
||||||
TrafficResetDate: now[:7],
|
TrafficResetDate: now[:7],
|
||||||
IOSpeedMBps: cfg.IOSpeedMBps,
|
IOSpeedMBps: cfg.IOSpeedMBps,
|
||||||
IPv6: ipv6,
|
PublicIPv4s: publicIPv4s,
|
||||||
IPv6PrefixLen: ipv6PrefixLen,
|
IPv6Addresses: ipv6Assignments,
|
||||||
IPv6Interface: ipv6Interface,
|
|
||||||
Status: "stopped",
|
Status: "stopped",
|
||||||
SSHPort: sshPort,
|
SSHPort: sshPort,
|
||||||
SSHPassword: func() string {
|
SSHPassword: func() string {
|
||||||
@@ -536,7 +558,9 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig
|
|||||||
SnapshotLimit: config.NormalizeSnapshotLimit(cfg.SnapshotLimit),
|
SnapshotLimit: config.NormalizeSnapshotLimit(cfg.SnapshotLimit),
|
||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
ExpiresAt: cfg.ExpiresAt,
|
ExpiresAt: cfg.ExpiresAt,
|
||||||
}, nil
|
}
|
||||||
|
container.NormalizeNetworkAssignments()
|
||||||
|
return container, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) StartContainer(id int) error {
|
func (m *Manager) StartContainer(id int) error {
|
||||||
@@ -547,6 +571,7 @@ func (m *Manager) StartContainer(id int) error {
|
|||||||
if err := m.validateHost(IsWindowsImage(c.Template)); err != nil {
|
if err := m.validateHost(IsWindowsImage(c.Template)); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
lxc.EnsureAssignedPublicIPv4s(c.PublicIPv4s)
|
||||||
name := c.VirshName()
|
name := c.VirshName()
|
||||||
if err := m.ensureDomainDefinition(c); err != nil {
|
if err := m.ensureDomainDefinition(c); err != nil {
|
||||||
fmt.Printf("Warning: failed to refresh KVM domain definition for %s: %v\n", name, err)
|
fmt.Printf("Warning: failed to refresh KVM domain definition for %s: %v\n", name, err)
|
||||||
@@ -597,7 +622,7 @@ func (m *Manager) StartContainer(id int) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if c.IPv6 != "" {
|
if c.IPv6 != "" || len(c.IPv6Addresses) > 0 {
|
||||||
if err := m.applyIPv6Runtime(c); err != nil {
|
if err := m.applyIPv6Runtime(c); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -661,7 +686,7 @@ func (m *Manager) DestroyContainer(id int) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) ReinstallContainer(id int, templateID string) error {
|
func (m *Manager) ReinstallContainer(id int, templateID string, authConfig ...lxc.ContainerConfig) error {
|
||||||
c := config.FindContainer(id)
|
c := config.FindContainer(id)
|
||||||
if c == nil {
|
if c == nil {
|
||||||
return fmt.Errorf("container not found: %d", id)
|
return fmt.Errorf("container not found: %d", id)
|
||||||
@@ -693,6 +718,19 @@ func (m *Manager) ReinstallContainer(id int, templateID string) error {
|
|||||||
SnapshotLimit: c.SnapshotLimit,
|
SnapshotLimit: c.SnapshotLimit,
|
||||||
ExpiresAt: c.ExpiresAt,
|
ExpiresAt: c.ExpiresAt,
|
||||||
}
|
}
|
||||||
|
if len(authConfig) > 0 && lxc.HasSSHAuthOptions(authConfig[0]) && !IsWindowsImage(templateID) {
|
||||||
|
sshAccess, err := lxc.ResolveReinstallSSHAccess(c.SSHPassword, authConfig[0])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if sshAccess.PublicKey != "" {
|
||||||
|
cfg.SSHAuthMode = lxc.SSHAuthKey
|
||||||
|
cfg.SSHPublicKey = sshAccess.PublicKey
|
||||||
|
} else {
|
||||||
|
cfg.SSHAuthMode = lxc.SSHAuthPassword
|
||||||
|
}
|
||||||
|
cfg.SSHPassword = sshAccess.Password
|
||||||
|
}
|
||||||
next, err := m.defineContainer(id, name, cfg, false)
|
next, err := m.defineContainer(id, name, cfg, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -1476,6 +1514,9 @@ func ensureDefaultNetwork() error {
|
|||||||
if out, err := exec.Command("virsh", "net-define", tmpFile).CombinedOutput(); err != nil {
|
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))
|
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
|
// Start and autostart the default network
|
||||||
if out, err := exec.Command("virsh", "net-info", "default").Output(); err == nil {
|
if out, err := exec.Command("virsh", "net-info", "default").Output(); err == nil {
|
||||||
@@ -1555,7 +1596,7 @@ func createEmptyDisk(target string, diskGB int) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func createWindowsUnattendISO(target, hostname, adminPassword, ipv6 string) error {
|
func createWindowsUnattendISO(target, hostname, adminPassword string, ipv6s []string) error {
|
||||||
tool := firstAvailableCommand("genisoimage", "mkisofs", "xorriso")
|
tool := firstAvailableCommand("genisoimage", "mkisofs", "xorriso")
|
||||||
if tool == "" {
|
if tool == "" {
|
||||||
return fmt.Errorf("one of genisoimage, mkisofs, xorriso is required for Windows unattended setup")
|
return fmt.Errorf("one of genisoimage, mkisofs, xorriso is required for Windows unattended setup")
|
||||||
@@ -1581,13 +1622,13 @@ func createWindowsUnattendISO(target, hostname, adminPassword, ipv6 string) erro
|
|||||||
if err := os.WriteFile(filepath.Join(setupScriptsDir, "SetupComplete.cmd"), []byte(windowsSetupCompleteCMD()), 0600); err != nil {
|
if err := os.WriteFile(filepath.Join(setupScriptsDir, "SetupComplete.cmd"), []byte(windowsSetupCompleteCMD()), 0600); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(filepath.Join(clicdDir, "FirstLogon.ps1"), []byte(windowsFirstLogonPowerShell(adminPassword, ipv6)), 0600); err != nil {
|
if err := os.WriteFile(filepath.Join(clicdDir, "FirstLogon.ps1"), []byte(windowsFirstLogonPowerShell(adminPassword, ipv6s)), 0600); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(filepath.Join(dir, "SetupComplete.cmd"), []byte(windowsSetupCompleteCMD()), 0600); err != nil {
|
if err := os.WriteFile(filepath.Join(dir, "SetupComplete.cmd"), []byte(windowsSetupCompleteCMD()), 0600); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(filepath.Join(dir, "FirstLogon.ps1"), []byte(windowsFirstLogonPowerShell(adminPassword, ipv6)), 0600); err != nil {
|
if err := os.WriteFile(filepath.Join(dir, "FirstLogon.ps1"), []byte(windowsFirstLogonPowerShell(adminPassword, ipv6s)), 0600); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
_ = os.Remove(target)
|
_ = os.Remove(target)
|
||||||
@@ -1697,7 +1738,7 @@ exit /b 0
|
|||||||
`
|
`
|
||||||
}
|
}
|
||||||
|
|
||||||
func windowsFirstLogonPowerShell(adminPassword, ipv6 string) string {
|
func windowsFirstLogonPowerShell(adminPassword string, ipv6s []string) string {
|
||||||
commands := []string{
|
commands := []string{
|
||||||
"$ErrorActionPreference='Continue'",
|
"$ErrorActionPreference='Continue'",
|
||||||
"$ProgressPreference='SilentlyContinue'",
|
"$ProgressPreference='SilentlyContinue'",
|
||||||
@@ -1727,9 +1768,10 @@ func windowsFirstLogonPowerShell(adminPassword, ipv6 string) string {
|
|||||||
"Get-Service QEMU-GA,qemu-ga -ErrorAction SilentlyContinue | Set-Service -StartupType Automatic",
|
"Get-Service QEMU-GA,qemu-ga -ErrorAction SilentlyContinue | Set-Service -StartupType Automatic",
|
||||||
"Start-Service QEMU-GA,qemu-ga -ErrorAction SilentlyContinue",
|
"Start-Service QEMU-GA,qemu-ga -ErrorAction SilentlyContinue",
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(ipv6) != "" {
|
ipv6s = normalizeKVMIPv6List(ipv6s)
|
||||||
|
if len(ipv6s) > 0 {
|
||||||
commands = append(commands,
|
commands = append(commands,
|
||||||
windowsIPv6PowerShell(strings.TrimSpace(ipv6)),
|
windowsIPv6PowerShell(ipv6s),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
commands = append(commands,
|
commands = append(commands,
|
||||||
@@ -1739,17 +1781,24 @@ func windowsFirstLogonPowerShell(adminPassword, ipv6 string) string {
|
|||||||
return strings.Join(commands, "\r\n") + "\r\n"
|
return strings.Join(commands, "\r\n") + "\r\n"
|
||||||
}
|
}
|
||||||
|
|
||||||
func windowsIPv6PowerShell(ipv6 string) string {
|
func windowsIPv6PowerShell(ipv6s []string) string {
|
||||||
ipv6 = strings.TrimSpace(ipv6)
|
ipv6s = normalizeKVMIPv6List(ipv6s)
|
||||||
if ipv6 == "" {
|
if len(ipv6s) == 0 {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
quoted := make([]string, 0, len(ipv6s))
|
||||||
|
for _, ipv6 := range ipv6s {
|
||||||
|
quoted = append(quoted, "'"+strings.ReplaceAll(ipv6, "'", "''")+"'")
|
||||||
|
}
|
||||||
return strings.Join([]string{
|
return strings.Join([]string{
|
||||||
|
"$clicdIPv6=@(" + strings.Join(quoted, ",") + ")",
|
||||||
"$iface=$null",
|
"$iface=$null",
|
||||||
"for ($i=0; $i -lt 60 -and -not $iface; $i++) { $iface=Get-NetAdapter | Where-Object { $_.Status -eq 'Up' -and $_.HardwareInterface } | Sort-Object ifIndex | Select-Object -First 1; if (-not $iface) { Start-Sleep -Seconds 5 } }",
|
"for ($i=0; $i -lt 60 -and -not $iface; $i++) { $iface=Get-NetAdapter | Where-Object { $_.Status -eq 'Up' -and $_.HardwareInterface } | Sort-Object ifIndex | Select-Object -First 1; if (-not $iface) { Start-Sleep -Seconds 5 } }",
|
||||||
"if ($iface) {",
|
"if ($iface) {",
|
||||||
" Get-NetIPAddress -InterfaceIndex $iface.ifIndex -AddressFamily IPv6 -ErrorAction SilentlyContinue | Where-Object { $_.IPAddress -eq '" + ipv6 + "' } | Remove-NetIPAddress -Confirm:$false -ErrorAction SilentlyContinue",
|
" foreach ($ip in $clicdIPv6) {",
|
||||||
" New-NetIPAddress -IPAddress '" + ipv6 + "' -PrefixLength 128 -InterfaceIndex $iface.ifIndex -SkipAsSource:$false -ErrorAction SilentlyContinue | Out-Null",
|
" Get-NetIPAddress -InterfaceIndex $iface.ifIndex -AddressFamily IPv6 -ErrorAction SilentlyContinue | Where-Object { $_.IPAddress -eq $ip } | Remove-NetIPAddress -Confirm:$false -ErrorAction SilentlyContinue",
|
||||||
|
" New-NetIPAddress -IPAddress $ip -PrefixLength 128 -InterfaceIndex $iface.ifIndex -SkipAsSource:$false -ErrorAction SilentlyContinue | Out-Null",
|
||||||
|
" }",
|
||||||
" Get-NetRoute -InterfaceIndex $iface.ifIndex -DestinationPrefix '::/0' -ErrorAction SilentlyContinue | Remove-NetRoute -Confirm:$false -ErrorAction SilentlyContinue",
|
" Get-NetRoute -InterfaceIndex $iface.ifIndex -DestinationPrefix '::/0' -ErrorAction SilentlyContinue | Remove-NetRoute -Confirm:$false -ErrorAction SilentlyContinue",
|
||||||
" New-NetRoute -DestinationPrefix '::/0' -InterfaceIndex $iface.ifIndex -NextHop '" + ipv6GatewayLinkLocal + "' -RouteMetric 100 -ErrorAction SilentlyContinue | Out-Null",
|
" New-NetRoute -DestinationPrefix '::/0' -InterfaceIndex $iface.ifIndex -NextHop '" + ipv6GatewayLinkLocal + "' -RouteMetric 100 -ErrorAction SilentlyContinue | Out-Null",
|
||||||
" Set-DnsClientServerAddress -InterfaceIndex $iface.ifIndex -ServerAddresses @('2001:4860:4860::8888','2606:4700:4700::1111') -ErrorAction SilentlyContinue",
|
" Set-DnsClientServerAddress -InterfaceIndex $iface.ifIndex -ServerAddresses @('2001:4860:4860::8888','2606:4700:4700::1111') -ErrorAction SilentlyContinue",
|
||||||
@@ -1761,13 +1810,20 @@ func shellQuoteWindows(value string) string {
|
|||||||
return `"` + strings.ReplaceAll(value, `"`, `\"`) + `"`
|
return `"` + strings.ReplaceAll(value, `"`, `\"`) + `"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func createSeedISO(seedPath, instanceID, hostname, password, mac, ipv6 string, image Image) error {
|
func createSeedISO(seedPath, instanceID, hostname, password, publicKey, mac string, ipv6s []string, image Image) error {
|
||||||
guestSetup := kvmSSHSetupScript(password)
|
guestSetup := kvmSSHSetupScript(password, publicKey)
|
||||||
if desktopSetup := kvmDesktopSetupScript(image); desktopSetup != "" {
|
if desktopSetup := kvmDesktopSetupScript(image); desktopSetup != "" {
|
||||||
guestSetup += "\n" + desktopSetup
|
guestSetup += "\n" + desktopSetup
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(ipv6) != "" {
|
ipv6s = normalizeKVMIPv6List(ipv6s)
|
||||||
guestSetup += "\n" + kvmIPv6SetupScript(ipv6)
|
if len(ipv6s) > 0 {
|
||||||
|
guestSetup += "\n" + kvmIPv6SetupScript(ipv6s)
|
||||||
|
}
|
||||||
|
authorizedKeys := ""
|
||||||
|
if publicKey != "" {
|
||||||
|
authorizedKeys = fmt.Sprintf(`
|
||||||
|
ssh_authorized_keys:
|
||||||
|
- %s`, yamlSingleQuote(publicKey))
|
||||||
}
|
}
|
||||||
setupScript := indentScript(guestSetup, 4)
|
setupScript := indentScript(guestSetup, 4)
|
||||||
userData := fmt.Sprintf(`#cloud-config
|
userData := fmt.Sprintf(`#cloud-config
|
||||||
@@ -1784,22 +1840,26 @@ chpasswd:
|
|||||||
type: text
|
type: text
|
||||||
users:
|
users:
|
||||||
- name: root
|
- name: root
|
||||||
lock_passwd: false
|
lock_passwd: false%s
|
||||||
runcmd:
|
runcmd:
|
||||||
- |
|
- |
|
||||||
%s
|
%s
|
||||||
`, hostname, password, setupScript)
|
`, hostname, password, authorizedKeys, setupScript)
|
||||||
metaData := fmt.Sprintf("instance-id: %s\nlocal-hostname: %s\n", instanceID, hostname)
|
metaData := fmt.Sprintf("instance-id: %s\nlocal-hostname: %s\n", instanceID, hostname)
|
||||||
ipv6Block := ""
|
ipv6Block := ""
|
||||||
if strings.TrimSpace(ipv6) != "" {
|
if len(ipv6s) > 0 {
|
||||||
|
addressLines := make([]string, 0, len(ipv6s))
|
||||||
|
for _, ipv6 := range ipv6s {
|
||||||
|
addressLines = append(addressLines, fmt.Sprintf(" - %s/128", ipv6))
|
||||||
|
}
|
||||||
ipv6Block = fmt.Sprintf(`
|
ipv6Block = fmt.Sprintf(`
|
||||||
addresses:
|
addresses:
|
||||||
- %s/128
|
%s
|
||||||
routes:
|
routes:
|
||||||
- to: default
|
- to: default
|
||||||
via: %s
|
via: %s
|
||||||
on-link: true
|
on-link: true
|
||||||
metric: 100`, ipv6, ipv6GatewayLinkLocal)
|
metric: 100`, strings.Join(addressLines, "\n"), ipv6GatewayLinkLocal)
|
||||||
}
|
}
|
||||||
networkConfig := fmt.Sprintf(`version: 2
|
networkConfig := fmt.Sprintf(`version: 2
|
||||||
ethernets:
|
ethernets:
|
||||||
@@ -1830,6 +1890,42 @@ ethernets:
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func configIPv6AssignmentAddresses(assignments []config.IPv6Assignment) []string {
|
||||||
|
values := make([]string, 0, len(assignments))
|
||||||
|
for _, item := range assignments {
|
||||||
|
if strings.TrimSpace(item.Address) != "" {
|
||||||
|
values = append(values, strings.TrimSpace(item.Address))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return values
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeKVMIPv6List(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 shellQuotedKVMIPv6List(values []string) string {
|
||||||
|
values = normalizeKVMIPv6List(values)
|
||||||
|
if len(values) == 0 {
|
||||||
|
return "''"
|
||||||
|
}
|
||||||
|
quoted := make([]string, 0, len(values))
|
||||||
|
for _, value := range values {
|
||||||
|
quoted = append(quoted, shellQuote(value))
|
||||||
|
}
|
||||||
|
return strings.Join(quoted, " ")
|
||||||
|
}
|
||||||
|
|
||||||
func indentScript(script string, spaces int) string {
|
func indentScript(script string, spaces int) string {
|
||||||
prefix := strings.Repeat(" ", spaces)
|
prefix := strings.Repeat(" ", spaces)
|
||||||
lines := strings.Split(strings.TrimRight(script, "\n"), "\n")
|
lines := strings.Split(strings.TrimRight(script, "\n"), "\n")
|
||||||
@@ -1839,6 +1935,10 @@ func indentScript(script string, spaces int) string {
|
|||||||
return strings.Join(lines, "\n")
|
return strings.Join(lines, "\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func yamlSingleQuote(value string) string {
|
||||||
|
return "'" + strings.ReplaceAll(value, "'", "''") + "'"
|
||||||
|
}
|
||||||
|
|
||||||
func isKVMDesktopTemplate(templateID string) bool {
|
func isKVMDesktopTemplate(templateID string) bool {
|
||||||
image := FindImage(templateID)
|
image := FindImage(templateID)
|
||||||
return image != nil && image.Desktop != ""
|
return image != nil && image.Desktop != ""
|
||||||
@@ -2285,7 +2385,8 @@ func runKVMSSHScript(client *ssh.Client, script string, description string, time
|
|||||||
var output []byte
|
var output []byte
|
||||||
go func() {
|
go func() {
|
||||||
var err error
|
var err error
|
||||||
output, err = session.CombinedOutput(script)
|
session.Stdin = strings.NewReader(script)
|
||||||
|
output, err = session.CombinedOutput("sh -s")
|
||||||
done <- err
|
done <- err
|
||||||
}()
|
}()
|
||||||
select {
|
select {
|
||||||
@@ -2300,9 +2401,14 @@ func runKVMSSHScript(client *ssh.Client, script string, description string, time
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func kvmSSHSetupScript(password string) string {
|
func kvmSSHSetupScript(password string, publicKeys ...string) string {
|
||||||
|
publicKey := ""
|
||||||
|
if len(publicKeys) > 0 {
|
||||||
|
publicKey = strings.TrimSpace(publicKeys[0])
|
||||||
|
}
|
||||||
return `set -u
|
return `set -u
|
||||||
ROOT_PASSWORD=` + shellQuote(password) + `
|
ROOT_PASSWORD=` + shellQuote(password) + `
|
||||||
|
SSH_PUBLIC_KEY=` + shellQuote(publicKey) + `
|
||||||
export DEBIAN_FRONTEND=noninteractive
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
if command -v apt-get >/dev/null 2>&1; then
|
if command -v apt-get >/dev/null 2>&1; then
|
||||||
if ! command -v sshd >/dev/null 2>&1 || ! command -v qemu-ga >/dev/null 2>&1; then
|
if ! command -v sshd >/dev/null 2>&1 || ! command -v qemu-ga >/dev/null 2>&1; then
|
||||||
@@ -2329,6 +2435,7 @@ fi
|
|||||||
mkdir -p /etc/ssh/sshd_config.d
|
mkdir -p /etc/ssh/sshd_config.d
|
||||||
cat > /etc/ssh/sshd_config.d/99-clicd-root.conf <<'EOF'
|
cat > /etc/ssh/sshd_config.d/99-clicd-root.conf <<'EOF'
|
||||||
PermitRootLogin yes
|
PermitRootLogin yes
|
||||||
|
PubkeyAuthentication yes
|
||||||
PasswordAuthentication yes
|
PasswordAuthentication yes
|
||||||
KbdInteractiveAuthentication yes
|
KbdInteractiveAuthentication yes
|
||||||
ChallengeResponseAuthentication yes
|
ChallengeResponseAuthentication yes
|
||||||
@@ -2336,11 +2443,21 @@ EOF
|
|||||||
if [ -f /etc/ssh/sshd_config ]; then
|
if [ -f /etc/ssh/sshd_config ]; then
|
||||||
grep -q '^PermitRootLogin ' /etc/ssh/sshd_config && sed -i 's/^PermitRootLogin .*/PermitRootLogin yes/' /etc/ssh/sshd_config || printf '\nPermitRootLogin yes\n' >> /etc/ssh/sshd_config
|
grep -q '^PermitRootLogin ' /etc/ssh/sshd_config && sed -i 's/^PermitRootLogin .*/PermitRootLogin yes/' /etc/ssh/sshd_config || printf '\nPermitRootLogin yes\n' >> /etc/ssh/sshd_config
|
||||||
grep -q '^#PermitRootLogin ' /etc/ssh/sshd_config && sed -i 's/^#PermitRootLogin .*/PermitRootLogin yes/' /etc/ssh/sshd_config || true
|
grep -q '^#PermitRootLogin ' /etc/ssh/sshd_config && sed -i 's/^#PermitRootLogin .*/PermitRootLogin yes/' /etc/ssh/sshd_config || true
|
||||||
|
grep -q '^PubkeyAuthentication ' /etc/ssh/sshd_config && sed -i 's/^PubkeyAuthentication .*/PubkeyAuthentication yes/' /etc/ssh/sshd_config || printf '\nPubkeyAuthentication yes\n' >> /etc/ssh/sshd_config
|
||||||
|
grep -q '^#PubkeyAuthentication ' /etc/ssh/sshd_config && sed -i 's/^#PubkeyAuthentication .*/PubkeyAuthentication yes/' /etc/ssh/sshd_config || true
|
||||||
grep -q '^PasswordAuthentication ' /etc/ssh/sshd_config && sed -i 's/^PasswordAuthentication .*/PasswordAuthentication yes/' /etc/ssh/sshd_config || printf '\nPasswordAuthentication yes\n' >> /etc/ssh/sshd_config
|
grep -q '^PasswordAuthentication ' /etc/ssh/sshd_config && sed -i 's/^PasswordAuthentication .*/PasswordAuthentication yes/' /etc/ssh/sshd_config || printf '\nPasswordAuthentication yes\n' >> /etc/ssh/sshd_config
|
||||||
grep -q '^#PasswordAuthentication ' /etc/ssh/sshd_config && sed -i 's/^#PasswordAuthentication .*/PasswordAuthentication yes/' /etc/ssh/sshd_config || true
|
grep -q '^#PasswordAuthentication ' /etc/ssh/sshd_config && sed -i 's/^#PasswordAuthentication .*/PasswordAuthentication yes/' /etc/ssh/sshd_config || true
|
||||||
grep -q '^KbdInteractiveAuthentication ' /etc/ssh/sshd_config && sed -i 's/^KbdInteractiveAuthentication .*/KbdInteractiveAuthentication yes/' /etc/ssh/sshd_config || printf '\nKbdInteractiveAuthentication yes\n' >> /etc/ssh/sshd_config
|
grep -q '^KbdInteractiveAuthentication ' /etc/ssh/sshd_config && sed -i 's/^KbdInteractiveAuthentication .*/KbdInteractiveAuthentication yes/' /etc/ssh/sshd_config || printf '\nKbdInteractiveAuthentication yes\n' >> /etc/ssh/sshd_config
|
||||||
grep -q '^#KbdInteractiveAuthentication ' /etc/ssh/sshd_config && sed -i 's/^#KbdInteractiveAuthentication .*/KbdInteractiveAuthentication yes/' /etc/ssh/sshd_config || true
|
grep -q '^#KbdInteractiveAuthentication ' /etc/ssh/sshd_config && sed -i 's/^#KbdInteractiveAuthentication .*/KbdInteractiveAuthentication yes/' /etc/ssh/sshd_config || true
|
||||||
fi
|
fi
|
||||||
|
if [ -n "$SSH_PUBLIC_KEY" ]; then
|
||||||
|
mkdir -p /root/.ssh
|
||||||
|
touch /root/.ssh/authorized_keys
|
||||||
|
grep -qxF "$SSH_PUBLIC_KEY" /root/.ssh/authorized_keys 2>/dev/null || printf '%s\n' "$SSH_PUBLIC_KEY" >> /root/.ssh/authorized_keys
|
||||||
|
chmod 700 /root/.ssh
|
||||||
|
chmod 600 /root/.ssh/authorized_keys
|
||||||
|
chown -R root:root /root/.ssh 2>/dev/null || true
|
||||||
|
fi
|
||||||
if command -v chpasswd >/dev/null 2>&1; then
|
if command -v chpasswd >/dev/null 2>&1; then
|
||||||
printf 'root:%s\n' "$ROOT_PASSWORD" | chpasswd || true
|
printf 'root:%s\n' "$ROOT_PASSWORD" | chpasswd || true
|
||||||
fi
|
fi
|
||||||
@@ -2571,7 +2688,7 @@ func (m *Manager) syncRunningNetworks() {
|
|||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
fmt.Printf("Warning: failed to sync KVM network for %s: %v\n", c.Name, err)
|
fmt.Printf("Warning: failed to sync KVM network for %s: %v\n", c.Name, err)
|
||||||
}
|
}
|
||||||
if c.IPv6 != "" {
|
if c.IPv6 != "" || len(c.IPv6Addresses) > 0 {
|
||||||
if err := m.applyIPv6Runtime(c); err != nil {
|
if err := m.applyIPv6Runtime(c); err != nil {
|
||||||
fmt.Printf("Warning: failed to sync KVM IPv6 for %s: %v\n", c.Name, err)
|
fmt.Printf("Warning: failed to sync KVM IPv6 for %s: %v\n", c.Name, err)
|
||||||
}
|
}
|
||||||
@@ -2585,7 +2702,7 @@ func (m *Manager) applyIPv6Guards() {
|
|||||||
if !c.IsKVM() || c.MACAddress == "" {
|
if !c.IsKVM() || c.MACAddress == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if c.IPv6 == "" {
|
if c.IPv6 == "" && len(c.IPv6Addresses) == 0 {
|
||||||
ensureKVMIPv6DenyRule("virbr0", c.MACAddress)
|
ensureKVMIPv6DenyRule("virbr0", c.MACAddress)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -2931,13 +3048,12 @@ func (m *Manager) AssignIPv6(id int) (*config.Container, error) {
|
|||||||
return nil, fmt.Errorf("container is not a KVM VM: %d", id)
|
return nil, fmt.Errorf("container is not a KVM VM: %d", id)
|
||||||
}
|
}
|
||||||
if c.IPv6 == "" {
|
if c.IPv6 == "" {
|
||||||
addr, prefixLen, iface, err := m.allocateIPv6ForContainer(id)
|
assignments, err := m.allocateIPv6AssignmentsForContainer(id, nil, 1, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
c.IPv6 = addr
|
c.IPv6Addresses = append(c.IPv6Addresses, assignments...)
|
||||||
c.IPv6PrefixLen = prefixLen
|
c.NormalizeNetworkAssignments()
|
||||||
c.IPv6Interface = iface
|
|
||||||
config.SaveConfig()
|
config.SaveConfig()
|
||||||
}
|
}
|
||||||
if err := m.applyIPv6Runtime(c); err != nil {
|
if err := m.applyIPv6Runtime(c); err != nil {
|
||||||
@@ -2947,9 +3063,10 @@ func (m *Manager) AssignIPv6(id int) (*config.Container, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) applyIPv6Runtime(c *config.Container) error {
|
func (m *Manager) applyIPv6Runtime(c *config.Container) error {
|
||||||
if c == nil || c.IPv6 == "" {
|
if c == nil || (c.IPv6 == "" && len(c.IPv6Addresses) == 0) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
c.NormalizeNetworkAssignments()
|
||||||
if err := m.applyIPv6HostRuntime(c); err != nil {
|
if err := m.applyIPv6HostRuntime(c); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -2960,7 +3077,13 @@ func (m *Manager) applyIPv6Runtime(c *config.Container) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ensureKVMIPv6NAT66(c.IPv6, c.IPv6Interface)
|
for _, assignment := range c.IPv6Addresses {
|
||||||
|
uplink := assignment.Interface
|
||||||
|
if uplink == "" {
|
||||||
|
uplink = c.IPv6Interface
|
||||||
|
}
|
||||||
|
ensureKVMIPv6NAT66(assignment.Address, uplink)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2976,9 +3099,10 @@ func shouldLogIPv6GuestWarning(id int) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) applyIPv6HostRuntime(c *config.Container) error {
|
func (m *Manager) applyIPv6HostRuntime(c *config.Container) error {
|
||||||
if c == nil || c.IPv6 == "" {
|
if c == nil || (c.IPv6 == "" && len(c.IPv6Addresses) == 0) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
c.NormalizeNetworkAssignments()
|
||||||
if c.IPv6Interface == "" {
|
if c.IPv6Interface == "" {
|
||||||
prefixes := lxc.DetectPublicIPv6Prefixes()
|
prefixes := lxc.DetectPublicIPv6Prefixes()
|
||||||
if len(prefixes) == 0 {
|
if len(prefixes) == 0 {
|
||||||
@@ -2986,6 +3110,14 @@ func (m *Manager) applyIPv6HostRuntime(c *config.Container) error {
|
|||||||
}
|
}
|
||||||
c.IPv6Interface = prefixes[0].Interface
|
c.IPv6Interface = prefixes[0].Interface
|
||||||
c.IPv6PrefixLen = prefixes[0].PrefixLen
|
c.IPv6PrefixLen = prefixes[0].PrefixLen
|
||||||
|
for i := range c.IPv6Addresses {
|
||||||
|
if c.IPv6Addresses[i].Interface == "" {
|
||||||
|
c.IPv6Addresses[i].Interface = c.IPv6Interface
|
||||||
|
}
|
||||||
|
if c.IPv6Addresses[i].PrefixLen == 0 {
|
||||||
|
c.IPv6Addresses[i].PrefixLen = c.IPv6PrefixLen
|
||||||
|
}
|
||||||
|
}
|
||||||
config.SaveConfig()
|
config.SaveConfig()
|
||||||
}
|
}
|
||||||
runQuiet("sysctl", "-w", "net.ipv6.conf.all.forwarding=1")
|
runQuiet("sysctl", "-w", "net.ipv6.conf.all.forwarding=1")
|
||||||
@@ -2997,14 +3129,20 @@ func (m *Manager) applyIPv6HostRuntime(c *config.Container) error {
|
|||||||
runQuiet("sysctl", "-w", "net.ipv6.conf."+bridge+".proxy_ndp=1")
|
runQuiet("sysctl", "-w", "net.ipv6.conf."+bridge+".proxy_ndp=1")
|
||||||
runQuiet("ip", "link", "set", bridge, "up")
|
runQuiet("ip", "link", "set", bridge, "up")
|
||||||
runQuiet("ip", "-6", "addr", "replace", ipv6GatewayLinkLocal+"/64", "dev", bridge)
|
runQuiet("ip", "-6", "addr", "replace", ipv6GatewayLinkLocal+"/64", "dev", bridge)
|
||||||
if out, err := exec.Command("ip", "-6", "route", "replace", c.IPv6+"/128", "dev", bridge).CombinedOutput(); err != nil {
|
for _, assignment := range c.IPv6Addresses {
|
||||||
return fmt.Errorf("failed to add IPv6 VM route: %v, output: %s", err, string(out))
|
uplink := assignment.Interface
|
||||||
|
if uplink == "" {
|
||||||
|
uplink = c.IPv6Interface
|
||||||
|
}
|
||||||
|
if out, err := exec.Command("ip", "-6", "route", "replace", assignment.Address+"/128", "dev", bridge).CombinedOutput(); err != nil {
|
||||||
|
return fmt.Errorf("failed to add IPv6 VM route: %v, output: %s", err, string(out))
|
||||||
|
}
|
||||||
|
if out, err := exec.Command("ip", "-6", "neigh", "replace", "proxy", assignment.Address, "dev", uplink).CombinedOutput(); err != nil {
|
||||||
|
return fmt.Errorf("failed to add IPv6 proxy NDP: %v, output: %s", err, string(out))
|
||||||
|
}
|
||||||
|
ensureKVMIPv6ForwardRules(assignment.Address, bridge)
|
||||||
|
ensureKVMIPv6AntiSpoofRules(assignment.Address, bridge, c.MACAddress)
|
||||||
}
|
}
|
||||||
if out, err := exec.Command("ip", "-6", "neigh", "replace", "proxy", c.IPv6, "dev", c.IPv6Interface).CombinedOutput(); err != nil {
|
|
||||||
return fmt.Errorf("failed to add IPv6 proxy NDP: %v, output: %s", err, string(out))
|
|
||||||
}
|
|
||||||
ensureKVMIPv6ForwardRules(c.IPv6, bridge)
|
|
||||||
ensureKVMIPv6AntiSpoofRules(c.IPv6, bridge, c.MACAddress)
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3070,16 +3208,23 @@ func removeKVMIPv6Runtime(c *config.Container) {
|
|||||||
}
|
}
|
||||||
bridge := "virbr0"
|
bridge := "virbr0"
|
||||||
removeKVMIPv6DenyRule(bridge, c.MACAddress)
|
removeKVMIPv6DenyRule(bridge, c.MACAddress)
|
||||||
if c.IPv6 == "" {
|
if c.IPv6 == "" && len(c.IPv6Addresses) == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
removeKVMIPv6NAT66(c.IPv6, c.IPv6Interface)
|
c.NormalizeNetworkAssignments()
|
||||||
removeKVMIPv6ForwardRules(c.IPv6, bridge)
|
for _, assignment := range c.IPv6Addresses {
|
||||||
removeKVMIPv6AntiSpoofRules(c.IPv6, bridge, c.MACAddress)
|
uplink := assignment.Interface
|
||||||
if c.IPv6Interface != "" {
|
if uplink == "" {
|
||||||
_ = exec.Command("ip", "-6", "neigh", "del", "proxy", c.IPv6, "dev", c.IPv6Interface).Run()
|
uplink = c.IPv6Interface
|
||||||
|
}
|
||||||
|
removeKVMIPv6NAT66(assignment.Address, uplink)
|
||||||
|
removeKVMIPv6ForwardRules(assignment.Address, bridge)
|
||||||
|
removeKVMIPv6AntiSpoofRules(assignment.Address, bridge, c.MACAddress)
|
||||||
|
if uplink != "" {
|
||||||
|
_ = exec.Command("ip", "-6", "neigh", "del", "proxy", assignment.Address, "dev", uplink).Run()
|
||||||
|
}
|
||||||
|
_ = exec.Command("ip", "-6", "route", "del", assignment.Address+"/128", "dev", bridge).Run()
|
||||||
}
|
}
|
||||||
_ = exec.Command("ip", "-6", "route", "del", c.IPv6+"/128", "dev", bridge).Run()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func removeKVMIPv6ForwardRules(ipv6 string, bridge string) {
|
func removeKVMIPv6ForwardRules(ipv6 string, bridge string) {
|
||||||
@@ -3143,13 +3288,14 @@ func deleteIP6Rule(rule []string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) applyGuestIPv6(c *config.Container) error {
|
func (m *Manager) applyGuestIPv6(c *config.Container) error {
|
||||||
if c == nil || c.IPv6 == "" {
|
if c == nil || (c.IPv6 == "" && len(c.IPv6Addresses) == 0) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
c.NormalizeNetworkAssignments()
|
||||||
if IsWindowsImage(c.Template) {
|
if IsWindowsImage(c.Template) {
|
||||||
return m.applyWindowsGuestIPv6(c)
|
return m.applyWindowsGuestIPv6(c)
|
||||||
}
|
}
|
||||||
script := kvmIPv6SetupScript(c.IPv6)
|
script := kvmIPv6SetupScript(c.IPv6AddressStrings())
|
||||||
if err := qemuGuestPing(c.VirshName()); err != nil {
|
if err := qemuGuestPing(c.VirshName()); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -3163,14 +3309,15 @@ func (m *Manager) applyWindowsGuestIPv6(c *config.Container) error {
|
|||||||
if err := qemuGuestPing(c.VirshName()); err != nil {
|
if err := qemuGuestPing(c.VirshName()); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
script := windowsIPv6PowerShell(c.IPv6)
|
script := windowsIPv6PowerShell(c.IPv6AddressStrings())
|
||||||
return qemuGuestExecCommand(c.VirshName(), "powershell.exe", []string{"-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script}, 60*time.Second)
|
return qemuGuestExecCommand(c.VirshName(), "powershell.exe", []string{"-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script}, 60*time.Second)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) applyGuestIPv6Runtime(c *config.Container) error {
|
func (m *Manager) applyGuestIPv6Runtime(c *config.Container) error {
|
||||||
if c == nil || c.IPv6 == "" {
|
if c == nil || (c.IPv6 == "" && len(c.IPv6Addresses) == 0) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
c.NormalizeNetworkAssignments()
|
||||||
qgaErr := m.applyGuestIPv6(c)
|
qgaErr := m.applyGuestIPv6(c)
|
||||||
if qgaErr == nil {
|
if qgaErr == nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -3183,7 +3330,7 @@ func (m *Manager) applyGuestIPv6Runtime(c *config.Container) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) applyGuestIPv6OverSSH(c *config.Container) error {
|
func (m *Manager) applyGuestIPv6OverSSH(c *config.Container) error {
|
||||||
if c == nil || c.IPv6 == "" {
|
if c == nil || (c.IPv6 == "" && len(c.IPv6Addresses) == 0) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if IsWindowsImage(c.Template) {
|
if IsWindowsImage(c.Template) {
|
||||||
@@ -3202,12 +3349,13 @@ func (m *Manager) applyGuestIPv6OverSSH(c *config.Container) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer client.Close()
|
defer client.Close()
|
||||||
return runKVMSSHScript(client, kvmIPv6SetupScript(c.IPv6), "KVM IPv6", 60*time.Second)
|
return runKVMSSHScript(client, kvmIPv6SetupScript(c.IPv6AddressStrings()), "KVM IPv6", 60*time.Second)
|
||||||
}
|
}
|
||||||
|
|
||||||
func kvmIPv6SetupScript(ipv6 string) string {
|
func kvmIPv6SetupScript(ipv6s []string) string {
|
||||||
|
ipv6s = normalizeKVMIPv6List(ipv6s)
|
||||||
return `set -eu
|
return `set -eu
|
||||||
IPV6_ADDR=` + shellQuote(ipv6) + `
|
IPV6_ADDRS="` + strings.Join(ipv6s, " ") + `"
|
||||||
IPV6_GW=` + shellQuote(ipv6GatewayLinkLocal) + `
|
IPV6_GW=` + shellQuote(ipv6GatewayLinkLocal) + `
|
||||||
IFACE="$(ip -o -4 route show default 2>/dev/null | awk '{print $5; exit}')"
|
IFACE="$(ip -o -4 route show default 2>/dev/null | awk '{print $5; exit}')"
|
||||||
if [ -z "$IFACE" ]; then
|
if [ -z "$IFACE" ]; then
|
||||||
@@ -3220,13 +3368,15 @@ fi
|
|||||||
sysctl -w net.ipv6.conf.all.disable_ipv6=0 >/dev/null 2>&1 || true
|
sysctl -w net.ipv6.conf.all.disable_ipv6=0 >/dev/null 2>&1 || true
|
||||||
sysctl -w net.ipv6.conf.default.disable_ipv6=0 >/dev/null 2>&1 || true
|
sysctl -w net.ipv6.conf.default.disable_ipv6=0 >/dev/null 2>&1 || true
|
||||||
sysctl -w net.ipv6.conf."$IFACE".disable_ipv6=0 >/dev/null 2>&1 || true
|
sysctl -w net.ipv6.conf."$IFACE".disable_ipv6=0 >/dev/null 2>&1 || true
|
||||||
ip -6 addr replace "$IPV6_ADDR/128" dev "$IFACE"
|
for IPV6_ADDR in $IPV6_ADDRS; do
|
||||||
|
ip -6 addr replace "$IPV6_ADDR/128" dev "$IFACE"
|
||||||
|
done
|
||||||
ip -6 route replace default via "$IPV6_GW" dev "$IFACE" onlink metric 100
|
ip -6 route replace default via "$IPV6_GW" dev "$IFACE" onlink metric 100
|
||||||
mkdir -p /usr/local/sbin /etc/systemd/system /etc/network/if-up.d /etc/local.d
|
mkdir -p /usr/local/sbin /etc/systemd/system /etc/network/if-up.d /etc/local.d
|
||||||
cat > /usr/local/sbin/clicd-kvm-ipv6-init <<'EOF'
|
cat > /usr/local/sbin/clicd-kvm-ipv6-init <<'EOF'
|
||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
set -eu
|
set -eu
|
||||||
IPV6_ADDR=` + shellQuote(ipv6) + `
|
IPV6_ADDRS="` + strings.Join(ipv6s, " ") + `"
|
||||||
IPV6_GW=` + shellQuote(ipv6GatewayLinkLocal) + `
|
IPV6_GW=` + shellQuote(ipv6GatewayLinkLocal) + `
|
||||||
IFACE="$(ip -o -4 route show default 2>/dev/null | awk '{print $5; exit}')"
|
IFACE="$(ip -o -4 route show default 2>/dev/null | awk '{print $5; exit}')"
|
||||||
if [ -z "$IFACE" ]; then
|
if [ -z "$IFACE" ]; then
|
||||||
@@ -3236,7 +3386,9 @@ fi
|
|||||||
sysctl -w net.ipv6.conf.all.disable_ipv6=0 >/dev/null 2>&1 || true
|
sysctl -w net.ipv6.conf.all.disable_ipv6=0 >/dev/null 2>&1 || true
|
||||||
sysctl -w net.ipv6.conf.default.disable_ipv6=0 >/dev/null 2>&1 || true
|
sysctl -w net.ipv6.conf.default.disable_ipv6=0 >/dev/null 2>&1 || true
|
||||||
sysctl -w net.ipv6.conf."$IFACE".disable_ipv6=0 >/dev/null 2>&1 || true
|
sysctl -w net.ipv6.conf."$IFACE".disable_ipv6=0 >/dev/null 2>&1 || true
|
||||||
ip -6 addr replace "$IPV6_ADDR/128" dev "$IFACE"
|
for IPV6_ADDR in $IPV6_ADDRS; do
|
||||||
|
ip -6 addr replace "$IPV6_ADDR/128" dev "$IFACE"
|
||||||
|
done
|
||||||
ip -6 route replace default via "$IPV6_GW" dev "$IFACE" onlink metric 100
|
ip -6 route replace default via "$IPV6_GW" dev "$IFACE" onlink metric 100
|
||||||
EOF
|
EOF
|
||||||
chmod +x /usr/local/sbin/clicd-kvm-ipv6-init
|
chmod +x /usr/local/sbin/clicd-kvm-ipv6-init
|
||||||
@@ -3275,15 +3427,43 @@ chmod +x /etc/network/if-up.d/clicd-kvm-ipv6
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) allocateIPv6ForContainer(id int) (string, int, string, error) {
|
func (m *Manager) allocateIPv6ForContainer(id int) (string, int, string, error) {
|
||||||
prefixes := lxc.DetectPublicIPv6Prefixes()
|
assignments, err := m.allocateIPv6AssignmentsForContainer(id, nil, 1, true)
|
||||||
if len(prefixes) == 0 {
|
|
||||||
return "", 0, "", fmt.Errorf("public IPv6 allocation is unavailable: no usable public IPv6 prefix found")
|
|
||||||
}
|
|
||||||
prefixInfo := prefixes[0]
|
|
||||||
prefix, err := netip.ParsePrefix(prefixInfo.Prefix)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", 0, "", err
|
return "", 0, "", err
|
||||||
}
|
}
|
||||||
|
if len(assignments) == 0 {
|
||||||
|
return "", 0, "", fmt.Errorf("no free IPv6 address")
|
||||||
|
}
|
||||||
|
return assignments[0].Address, assignments[0].PrefixLen, assignments[0].Interface, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) allocateIPv6AssignmentsForContainer(id int, requested []string, count int, auto bool) ([]config.IPv6Assignment, error) {
|
||||||
|
var err error
|
||||||
|
count, err = lxc.NormalizePublicIPAllocationCount(requested, count)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
prefixes := lxc.DetectPublicIPv6Prefixes()
|
||||||
|
if len(prefixes) == 0 {
|
||||||
|
return nil, fmt.Errorf("public IPv6 allocation is unavailable: no usable public IPv6 prefix found")
|
||||||
|
}
|
||||||
|
parsedPrefixes := make([]struct {
|
||||||
|
info lxc.IPv6PrefixInfo
|
||||||
|
prefix netip.Prefix
|
||||||
|
}, 0, len(prefixes))
|
||||||
|
for _, prefixInfo := range prefixes {
|
||||||
|
prefix, err := netip.ParsePrefix(prefixInfo.Prefix)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parsedPrefixes = append(parsedPrefixes, struct {
|
||||||
|
info lxc.IPv6PrefixInfo
|
||||||
|
prefix netip.Prefix
|
||||||
|
}{info: prefixInfo, prefix: prefix})
|
||||||
|
}
|
||||||
|
if len(parsedPrefixes) == 0 {
|
||||||
|
return nil, fmt.Errorf("public IPv6 allocation is unavailable: no valid IPv6 prefix found")
|
||||||
|
}
|
||||||
|
|
||||||
used := map[string]bool{}
|
used := map[string]bool{}
|
||||||
hostAddrs := map[string]bool{}
|
hostAddrs := map[string]bool{}
|
||||||
@@ -3291,21 +3471,69 @@ func (m *Manager) allocateIPv6ForContainer(id int) (string, int, string, error)
|
|||||||
hostAddrs[p.Address] = true
|
hostAddrs[p.Address] = true
|
||||||
}
|
}
|
||||||
for _, c := range config.AppConfig.Containers {
|
for _, c := range config.AppConfig.Containers {
|
||||||
|
if c.ID == id {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if c.IPv6 != "" {
|
if c.IPv6 != "" {
|
||||||
used[c.IPv6] = true
|
used[c.IPv6] = true
|
||||||
}
|
}
|
||||||
}
|
for _, ip := range c.IPv6Addresses {
|
||||||
for offset := uint64(0x2000 + id); offset < 0x100000; offset++ {
|
if ip.Address != "" {
|
||||||
addr, err := ipv6Add(prefix.Masked().Addr(), offset)
|
used[ip.Address] = true
|
||||||
if err != nil || !prefix.Contains(addr) {
|
}
|
||||||
break
|
|
||||||
}
|
|
||||||
candidate := addr.String()
|
|
||||||
if !used[candidate] && !hostAddrs[candidate] {
|
|
||||||
return candidate, prefix.Bits(), prefixInfo.Interface, nil
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return "", 0, "", fmt.Errorf("no free IPv6 address in %s", prefix.String())
|
result := []config.IPv6Assignment{}
|
||||||
|
selected := map[string]bool{}
|
||||||
|
for _, raw := range requested {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" || selected[raw] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
addr, err := netip.ParseAddr(raw)
|
||||||
|
if err != nil || !addr.Is6() {
|
||||||
|
return nil, fmt.Errorf("requested IPv6 %s is not valid", raw)
|
||||||
|
}
|
||||||
|
matchIndex := -1
|
||||||
|
for i, item := range parsedPrefixes {
|
||||||
|
if item.prefix.Contains(addr) {
|
||||||
|
matchIndex = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if matchIndex < 0 {
|
||||||
|
return nil, fmt.Errorf("requested IPv6 %s is not in the configured IPv6 prefixes", raw)
|
||||||
|
}
|
||||||
|
if hostAddrs[raw] {
|
||||||
|
return nil, fmt.Errorf("requested IPv6 %s is used by host", raw)
|
||||||
|
}
|
||||||
|
if used[raw] {
|
||||||
|
return nil, fmt.Errorf("requested IPv6 %s is already assigned", raw)
|
||||||
|
}
|
||||||
|
selected[raw] = true
|
||||||
|
used[raw] = true
|
||||||
|
result = append(result, config.IPv6Assignment{Address: raw, PrefixLen: parsedPrefixes[matchIndex].prefix.Bits(), Interface: parsedPrefixes[matchIndex].info.Interface})
|
||||||
|
}
|
||||||
|
if len(result) >= count || !auto {
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
for _, item := range parsedPrefixes {
|
||||||
|
for offset := uint64(0x2000 + id); offset < 0x100000; offset++ {
|
||||||
|
addr, err := ipv6Add(item.prefix.Masked().Addr(), offset)
|
||||||
|
if err != nil || !item.prefix.Contains(addr) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
candidate := addr.String()
|
||||||
|
if !used[candidate] && !hostAddrs[candidate] {
|
||||||
|
used[candidate] = true
|
||||||
|
result = append(result, config.IPv6Assignment{Address: candidate, PrefixLen: item.prefix.Bits(), Interface: item.info.Interface})
|
||||||
|
if len(result) >= count {
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("no free IPv6 address in configured prefixes")
|
||||||
}
|
}
|
||||||
|
|
||||||
func ipv6Add(base netip.Addr, offset uint64) (netip.Addr, error) {
|
func ipv6Add(base netip.Addr, offset uint64) (netip.Addr, error) {
|
||||||
|
|||||||
+1161
-53
File diff suppressed because it is too large
Load Diff
+473
-133
@@ -218,24 +218,37 @@ func NewManager() *Manager {
|
|||||||
|
|
||||||
// ContainerConfig defines container creation parameters
|
// ContainerConfig defines container creation parameters
|
||||||
type ContainerConfig struct {
|
type ContainerConfig struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Virtualization string `json:"virtualization,omitempty"`
|
Virtualization string `json:"virtualization,omitempty"`
|
||||||
TemplateID string `json:"template_id"`
|
TemplateID string `json:"template_id"`
|
||||||
VCPU float64 `json:"vcpu"`
|
VCPU float64 `json:"vcpu"`
|
||||||
CPUPercent int `json:"cpu_percent"`
|
CPUPercent int `json:"cpu_percent"`
|
||||||
RAMMB int `json:"ram_mb"`
|
RAMMB int `json:"ram_mb"`
|
||||||
DiskGB int `json:"disk_gb"`
|
DiskGB int `json:"disk_gb"`
|
||||||
NetworkBWMbps int `json:"network_bw_mbps"`
|
NetworkBWMbps int `json:"network_bw_mbps"`
|
||||||
MonthlyTrafficGB int `json:"monthly_traffic_gb"`
|
MonthlyTrafficGB int `json:"monthly_traffic_gb"`
|
||||||
TrafficMode string `json:"traffic_mode"` // "total" or "in_out"
|
TrafficMode string `json:"traffic_mode"` // "total" or "in_out"
|
||||||
TrafficInGB int `json:"traffic_in_gb"` // 0=unlimited
|
TrafficInGB int `json:"traffic_in_gb"` // 0=unlimited
|
||||||
TrafficOutGB int `json:"traffic_out_gb"` // 0=unlimited
|
TrafficOutGB int `json:"traffic_out_gb"` // 0=unlimited
|
||||||
IOSpeedMBps int `json:"io_speed_mbps"`
|
IOSpeedMBps int `json:"io_speed_mbps"`
|
||||||
ExtraPorts []int `json:"extra_ports"`
|
ExtraPorts []int `json:"extra_ports"`
|
||||||
PortMappingCount int `json:"port_mapping_count"`
|
PortMappingCount int `json:"port_mapping_count"`
|
||||||
SnapshotLimit int `json:"snapshot_limit"`
|
AssignNAT *bool `json:"assign_nat,omitempty"`
|
||||||
AssignIPv6 bool `json:"assign_ipv6"`
|
SnapshotLimit int `json:"snapshot_limit"`
|
||||||
ExpiresAt string `json:"expires_at"`
|
AssignIPv4 bool `json:"assign_ipv4"`
|
||||||
|
IPv4Count int `json:"ipv4_count,omitempty"`
|
||||||
|
PublicIPv4s []string `json:"public_ipv4s,omitempty"`
|
||||||
|
AssignIPv6 bool `json:"assign_ipv6"`
|
||||||
|
IPv6Count int `json:"ipv6_count,omitempty"`
|
||||||
|
IPv6Addresses []string `json:"ipv6_addresses,omitempty"`
|
||||||
|
SSHAuthMode string `json:"ssh_auth_mode,omitempty"`
|
||||||
|
SSHPassword string `json:"ssh_password,omitempty"`
|
||||||
|
SSHPublicKey string `json:"ssh_public_key,omitempty"`
|
||||||
|
ExpiresAt string `json:"expires_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg ContainerConfig) WantsNAT() bool {
|
||||||
|
return cfg.AssignNAT == nil || *cfg.AssignNAT
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateContainer creates a new LXC container. Uses ct-{id} as LXC name internally.
|
// CreateContainer creates a new LXC container. Uses ct-{id} as LXC name internally.
|
||||||
@@ -244,8 +257,11 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
|
|||||||
if tmpl == nil {
|
if tmpl == nil {
|
||||||
return fmt.Errorf("template not found: %s", cfg.TemplateID)
|
return fmt.Errorf("template not found: %s", cfg.TemplateID)
|
||||||
}
|
}
|
||||||
if cfg.PortMappingCount < 2 {
|
if cfg.WantsNAT() && cfg.PortMappingCount < 2 {
|
||||||
cfg.PortMappingCount = 2
|
cfg.PortMappingCount = 2
|
||||||
|
} else if !cfg.WantsNAT() {
|
||||||
|
cfg.PortMappingCount = 0
|
||||||
|
cfg.ExtraPorts = nil
|
||||||
}
|
}
|
||||||
if cfg.SnapshotLimit <= 0 {
|
if cfg.SnapshotLimit <= 0 {
|
||||||
cfg.SnapshotLimit = config.DefaultSnapshotLimit
|
cfg.SnapshotLimit = config.DefaultSnapshotLimit
|
||||||
@@ -257,6 +273,10 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
|
|||||||
if config.FindContainerByName(cfg.Name) != nil {
|
if config.FindContainerByName(cfg.Name) != nil {
|
||||||
return fmt.Errorf("container name already exists: %s", cfg.Name)
|
return fmt.Errorf("container name already exists: %s", cfg.Name)
|
||||||
}
|
}
|
||||||
|
sshAccess, err := ResolveCreateSSHAccess(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// Allocate ID and build LXC name
|
// Allocate ID and build LXC name
|
||||||
id := config.AllocateContainerID()
|
id := config.AllocateContainerID()
|
||||||
@@ -296,50 +316,64 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
ipv6 := ""
|
publicIPv4s, err := AllocatePublicIPv4Assignments(id, cfg.PublicIPv4s, cfg.IPv4Count, cfg.AssignIPv4)
|
||||||
ipv6PrefixLen := 0
|
if err != nil {
|
||||||
ipv6Interface := ""
|
_ = m.cleanupContainerStorage(lxcName)
|
||||||
if cfg.AssignIPv6 {
|
return err
|
||||||
assigned, prefixLen, iface, err := m.allocateIPv6ForContainer(id)
|
}
|
||||||
|
|
||||||
|
ipv6Assignments := []config.IPv6Assignment{}
|
||||||
|
if cfg.AssignIPv6 || len(cfg.IPv6Addresses) > 0 {
|
||||||
|
assigned, err := m.allocateIPv6AssignmentsForContainer(id, cfg.IPv6Addresses, cfg.IPv6Count, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = m.cleanupContainerStorage(lxcName)
|
_ = m.cleanupContainerStorage(lxcName)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
ipv6 = assigned
|
ipv6Assignments = assigned
|
||||||
ipv6PrefixLen = prefixLen
|
if err := m.applyIPv6Config(lxcName, ipv6AssignmentAddresses(ipv6Assignments)...); err != nil {
|
||||||
ipv6Interface = iface
|
|
||||||
if err := m.applyIPv6Config(lxcName, ipv6); err != nil {
|
|
||||||
_ = m.cleanupContainerStorage(lxcName)
|
_ = m.cleanupContainerStorage(lxcName)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sshPort := config.AllocateSSHPort()
|
sshPassword := sshAccess.Password
|
||||||
sshPassword := generateRandomString(16)
|
|
||||||
|
|
||||||
// Setup default port mappings (SSH only)
|
sshPort := 0
|
||||||
portMappings := SetupDefaultPortMappings(sshPort)
|
portMappings := []config.PortMapping{}
|
||||||
tempC := &config.Container{PortMappings: portMappings}
|
if cfg.WantsNAT() {
|
||||||
|
sshPort = config.AllocateSSHPort()
|
||||||
|
|
||||||
extraPorts := cfg.ExtraPorts
|
// Setup default port mappings (SSH only)
|
||||||
if len(extraPorts) == 0 && cfg.PortMappingCount > 1 {
|
portMappings = SetupDefaultPortMappings(sshPort)
|
||||||
extraPorts = allocateDefaultEqualPorts(tempC, cfg.PortMappingCount-1)
|
defaultHostIP := defaultPortMappingHostIP(publicIPv4s)
|
||||||
}
|
if defaultHostIP != "" {
|
||||||
for _, containerPort := range extraPorts {
|
for i := range portMappings {
|
||||||
if containerPort <= 0 {
|
portMappings[i].HostIP = defaultHostIP
|
||||||
continue
|
}
|
||||||
}
|
}
|
||||||
pm, err := normalizePortMapping(tempC, -1, config.PortMapping{
|
tempC := &config.Container{ID: id, PublicIPv4s: publicIPv4s, PortMappings: portMappings}
|
||||||
ContainerPort: containerPort,
|
|
||||||
HostPort: containerPort,
|
extraPorts := cfg.ExtraPorts
|
||||||
Protocol: "tcp",
|
if len(extraPorts) == 0 && cfg.PortMappingCount > 1 {
|
||||||
Description: fmt.Sprintf("Port-%d", containerPort),
|
extraPorts = allocateDefaultEqualPorts(tempC, cfg.PortMappingCount-1)
|
||||||
})
|
}
|
||||||
if err != nil {
|
for _, containerPort := range extraPorts {
|
||||||
continue
|
if containerPort <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pm, err := normalizePortMapping(tempC, -1, config.PortMapping{
|
||||||
|
ContainerPort: containerPort,
|
||||||
|
HostPort: containerPort,
|
||||||
|
HostIP: defaultHostIP,
|
||||||
|
Protocol: "tcp",
|
||||||
|
Description: fmt.Sprintf("Port-%d", containerPort),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
tempC.PortMappings = append(tempC.PortMappings, pm)
|
||||||
|
portMappings = tempC.PortMappings
|
||||||
}
|
}
|
||||||
tempC.PortMappings = append(tempC.PortMappings, pm)
|
|
||||||
portMappings = tempC.PortMappings
|
|
||||||
}
|
}
|
||||||
|
|
||||||
now := time.Now().Format("2006-01-02 15:04:05")
|
now := time.Now().Format("2006-01-02 15:04:05")
|
||||||
@@ -368,9 +402,8 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
|
|||||||
IOSpeedMBps: cfg.IOSpeedMBps,
|
IOSpeedMBps: cfg.IOSpeedMBps,
|
||||||
Status: "stopped",
|
Status: "stopped",
|
||||||
IP: "",
|
IP: "",
|
||||||
IPv6: ipv6,
|
PublicIPv4s: publicIPv4s,
|
||||||
IPv6PrefixLen: ipv6PrefixLen,
|
IPv6Addresses: ipv6Assignments,
|
||||||
IPv6Interface: ipv6Interface,
|
|
||||||
VNCPort: 0,
|
VNCPort: 0,
|
||||||
SSHPort: sshPort,
|
SSHPort: sshPort,
|
||||||
SSHPassword: sshPassword,
|
SSHPassword: sshPassword,
|
||||||
@@ -380,19 +413,27 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
|
|||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
ExpiresAt: cfg.ExpiresAt,
|
ExpiresAt: cfg.ExpiresAt,
|
||||||
}
|
}
|
||||||
|
container.NormalizeNetworkAssignments()
|
||||||
config.AddContainer(container)
|
config.AddContainer(container)
|
||||||
|
|
||||||
// Pre-configure network and SSH in the rootfs before first boot.
|
// Pre-configure network and SSH in the rootfs before first boot.
|
||||||
rootfsPath := filepath.Join(m.LxcPath, lxcName, "rootfs")
|
rootfsPath := filepath.Join(m.LxcPath, lxcName, "rootfs")
|
||||||
m.preconfigureNetwork(rootfsPath, cfg.TemplateID)
|
m.preconfigureNetwork(rootfsPath, cfg.TemplateID)
|
||||||
if ipv6 != "" {
|
if len(ipv6Assignments) > 0 {
|
||||||
if err := installContainerIPv6Init(rootfsPath, ipv6); err != nil {
|
if err := installContainerIPv6Init(rootfsPath, ipv6AssignmentAddresses(ipv6Assignments)...); err != nil {
|
||||||
fmt.Printf("Warning: failed to install IPv6 init in %s: %v\n", lxcName, err)
|
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)
|
fmt.Printf("Warning: failed to pre-configure SSH in %s: %v\n", lxcName, err)
|
||||||
}
|
}
|
||||||
|
if sshAccess.PublicKey != "" {
|
||||||
|
if err := m.installRootAuthorizedKey(rootfsPath, sshAccess.PublicKey); err != nil {
|
||||||
|
_ = m.cleanupContainerStorage(lxcName)
|
||||||
|
config.RemoveContainer(id)
|
||||||
|
return fmt.Errorf("failed to install SSH public key: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if err := m.shiftRootfsForUnprivileged(lxcName); err != nil {
|
if err := m.shiftRootfsForUnprivileged(lxcName); err != nil {
|
||||||
_ = m.cleanupContainerStorage(lxcName)
|
_ = m.cleanupContainerStorage(lxcName)
|
||||||
@@ -402,8 +443,7 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
|
|||||||
|
|
||||||
// Set root password AFTER shiftRootfsForUnprivileged,
|
// Set root password AFTER shiftRootfsForUnprivileged,
|
||||||
// otherwise /etc/shadow ownership breaks and SSHD cannot authenticate.
|
// otherwise /etc/shadow ownership breaks and SSHD cannot authenticate.
|
||||||
if err := m.runRootfsCommand(rootfsPath,
|
if err := m.setRootfsPassword(rootfsPath, sshPassword); err != nil {
|
||||||
"sh", "-c", fmt.Sprintf("printf '%%s:%%s\\n' root %s | chpasswd", shellQuote(sshPassword))); err != nil {
|
|
||||||
fmt.Printf("Warning: failed to set root password in %s: %v\n", lxcName, err)
|
fmt.Printf("Warning: failed to set root password in %s: %v\n", lxcName, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -472,11 +512,11 @@ IPv6AcceptRA=no
|
|||||||
}
|
}
|
||||||
|
|
||||||
// preconfigureSSH installs and configures SSH directly in the rootfs before first boot.
|
// 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
|
_ = templateID
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
cmd, err := m.rootfsCommand(rootfsPath, "sh", "-c", sshSetupScript(password, false))
|
cmd, err := m.rootfsCommand(rootfsPath, "sh", "-c", sshSetupScript(false))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -526,7 +566,7 @@ func (m *Manager) applyResourceLimits(lxcName string, cfg ContainerConfig) error
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
apparmorProfile, err := findAppArmorProfile()
|
apparmorProfile, err := appArmorProfileForTemplate(cfg.TemplateID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -941,6 +981,26 @@ func findAppArmorProfile() (string, error) {
|
|||||||
return "", errors.New("required LXC AppArmor profile not loaded")
|
return "", errors.New("required LXC AppArmor profile not loaded")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func appArmorProfileForTemplate(templateID string) (string, error) {
|
||||||
|
if systemdTemplateNeedsUnconfinedAppArmor(templateID) {
|
||||||
|
return "unconfined", nil
|
||||||
|
}
|
||||||
|
return findAppArmorProfile()
|
||||||
|
}
|
||||||
|
|
||||||
|
func systemdTemplateNeedsUnconfinedAppArmor(templateID string) bool {
|
||||||
|
id := strings.ToLower(strings.TrimSpace(templateID))
|
||||||
|
if id == "" || strings.Contains(id, "alpine") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, token := range []string{"ubuntu", "debian", "centos", "fedora", "rocky", "rockylinux", "archlinux"} {
|
||||||
|
if strings.Contains(id, token) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func unprivilegedIDMap() (int, int, error) {
|
func unprivilegedIDMap() (int, int, error) {
|
||||||
if err := ensureSubIDRange("/etc/subuid", "root", 100000, 65536); err != nil {
|
if err := ensureSubIDRange("/etc/subuid", "root", 100000, 65536); err != nil {
|
||||||
return 0, 0, err
|
return 0, 0, err
|
||||||
@@ -993,6 +1053,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)
|
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 {
|
func (m *Manager) shiftRootfsForUnprivileged(lxcName string) error {
|
||||||
uidBase, gidBase, err := unprivilegedIDMap()
|
uidBase, gidBase, err := unprivilegedIDMap()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1000,6 +1081,9 @@ func (m *Manager) shiftRootfsForUnprivileged(lxcName string) error {
|
|||||||
}
|
}
|
||||||
rootfsPath := filepath.Join(m.LxcPath, lxcName, "rootfs")
|
rootfsPath := filepath.Join(m.LxcPath, lxcName, "rootfs")
|
||||||
marker := filepath.Join(rootfsPath, ".clicd-unprivileged-shifted")
|
marker := filepath.Join(rootfsPath, ".clicd-unprivileged-shifted")
|
||||||
|
if err := m.ensureUnprivilegedLXCPathAccess(lxcName); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if _, err := os.Stat(marker); err == nil {
|
if _, err := os.Stat(marker); err == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -1174,27 +1258,31 @@ func (m *Manager) StartContainer(id int) error {
|
|||||||
NetworkBWMbps: c.NetworkBWMbps,
|
NetworkBWMbps: c.NetworkBWMbps,
|
||||||
MonthlyTrafficGB: c.MonthlyTrafficGB,
|
MonthlyTrafficGB: c.MonthlyTrafficGB,
|
||||||
IOSpeedMBps: c.IOSpeedMBps,
|
IOSpeedMBps: c.IOSpeedMBps,
|
||||||
AssignIPv6: c.IPv6 != "",
|
AssignIPv6: c.IPv6 != "" || len(c.IPv6Addresses) > 0,
|
||||||
ExpiresAt: c.ExpiresAt,
|
ExpiresAt: c.ExpiresAt,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if c.IPv6 != "" {
|
if c.IPv6 != "" || len(c.IPv6Addresses) > 0 {
|
||||||
if err := m.applyIPv6Config(lxcName, c.IPv6); err != nil {
|
c.NormalizeNetworkAssignments()
|
||||||
|
if err := m.applyIPv6Config(lxcName, c.IPv6AddressStrings()...); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := m.ApplyIPv6(id); err != nil {
|
if err := m.ApplyIPv6(id); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
EnsureAssignedPublicIPv4s(c.PublicIPv4s)
|
||||||
|
|
||||||
logFile := filepath.Join(os.TempDir(), "clicd-"+lxcName+"-start.log")
|
logFile, consoleLog, output, err := m.startLXCContainerDaemon(lxcName)
|
||||||
os.Remove(logFile)
|
|
||||||
cmd := exec.Command("lxc-start", "-n", lxcName, "-d", "--logfile", logFile, "--logpriority", "DEBUG")
|
|
||||||
output, err := cmd.CombinedOutput()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to start container: %v, output: %s, lxc log: %s", err, string(output), tailFile(logFile, 80))
|
config.UpdateContainerStatus(id, "stopped")
|
||||||
|
return fmt.Errorf("failed to start container: %v, output: %s, lxc log: %s, console: %s", err, string(output), tailFile(logFile, 80), tailFile(consoleLog, 80))
|
||||||
|
}
|
||||||
|
if err := m.waitForLXCStartup(lxcName, logFile, consoleLog); err != nil {
|
||||||
|
config.UpdateContainerStatus(id, "stopped")
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
config.UpdateContainerStatus(id, "running")
|
config.UpdateContainerStatus(id, "running")
|
||||||
@@ -1239,7 +1327,7 @@ func (m *Manager) StartContainer(id int) error {
|
|||||||
if err := m.ApplyPortMappings(id); err != nil {
|
if err := m.ApplyPortMappings(id); err != nil {
|
||||||
fmt.Printf("Warning: failed to apply port mappings: %v\n", err)
|
fmt.Printf("Warning: failed to apply port mappings: %v\n", err)
|
||||||
}
|
}
|
||||||
if c.IPv6 != "" {
|
if c.IPv6 != "" || len(c.IPv6Addresses) > 0 {
|
||||||
if err := m.ApplyIPv6(id); err != nil {
|
if err := m.ApplyIPv6(id); err != nil {
|
||||||
fmt.Printf("Warning: failed to apply IPv6 routing for %s: %v\n", lxcName, err)
|
fmt.Printf("Warning: failed to apply IPv6 routing for %s: %v\n", lxcName, err)
|
||||||
}
|
}
|
||||||
@@ -1249,6 +1337,41 @@ func (m *Manager) StartContainer(id int) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *Manager) startLXCContainerDaemon(lxcName string) (string, string, []byte, error) {
|
||||||
|
logFile := filepath.Join(os.TempDir(), "clicd-"+lxcName+"-start.log")
|
||||||
|
consoleLog := filepath.Join(os.TempDir(), "clicd-"+lxcName+"-console.log")
|
||||||
|
os.Remove(logFile)
|
||||||
|
os.Remove(consoleLog)
|
||||||
|
cmd := exec.Command("lxc-start", "-n", lxcName, "-d", "--logfile", logFile, "--logpriority", "DEBUG", "--console-log", consoleLog)
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
return logFile, consoleLog, output, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) waitForLXCStartup(lxcName, logFile, consoleLog string) error {
|
||||||
|
runningChecks := 0
|
||||||
|
lastStatus := "unknown"
|
||||||
|
for retry := 0; retry < 10; retry++ {
|
||||||
|
time.Sleep(1 * time.Second)
|
||||||
|
status, err := m.GetContainerStatus(lxcName)
|
||||||
|
if err != nil {
|
||||||
|
lastStatus = "unknown"
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lastStatus = status
|
||||||
|
if status == "running" {
|
||||||
|
runningChecks++
|
||||||
|
if runningChecks >= 3 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if runningChecks > 0 || retry >= 1 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Errorf("container exited immediately after start (status: %s), lxc log: %s, console: %s", lastStatus, tailFile(logFile, 80), tailFile(consoleLog, 80))
|
||||||
|
}
|
||||||
|
|
||||||
// applyBandwidthLimit applies tc-based bandwidth limit on container's veth interface
|
// applyBandwidthLimit applies tc-based bandwidth limit on container's veth interface
|
||||||
// ApplyContainerLimits re-applies resource limits (CPU, RAM, IO, BW) to a running container.
|
// ApplyContainerLimits re-applies resource limits (CPU, RAM, IO, BW) to a running container.
|
||||||
func (m *Manager) ApplyContainerLimits(c *config.Container) error {
|
func (m *Manager) ApplyContainerLimits(c *config.Container) error {
|
||||||
@@ -1530,8 +1653,17 @@ func (m *Manager) DestroyContainer(id int) error {
|
|||||||
return fmt.Errorf("container not found: %d", id)
|
return fmt.Errorf("container not found: %d", id)
|
||||||
}
|
}
|
||||||
lxcName := c.LxcName()
|
lxcName := c.LxcName()
|
||||||
if c.IPv6 != "" && c.IPv6Interface != "" {
|
if c.IPv6 != "" || len(c.IPv6Addresses) > 0 {
|
||||||
removeHostIPv6Routing(c.IPv6, c.IPv6Interface)
|
c.NormalizeNetworkAssignments()
|
||||||
|
for _, assignment := range c.IPv6Addresses {
|
||||||
|
uplink := assignment.Interface
|
||||||
|
if uplink == "" {
|
||||||
|
uplink = c.IPv6Interface
|
||||||
|
}
|
||||||
|
if uplink != "" {
|
||||||
|
removeHostIPv6Routing(assignment.Address, uplink)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := m.StopContainer(id); err != nil {
|
if err := m.StopContainer(id); err != nil {
|
||||||
@@ -1626,7 +1758,7 @@ func (m *Manager) EnsureSSH(id int) error {
|
|||||||
config.SaveConfig()
|
config.SaveConfig()
|
||||||
}
|
}
|
||||||
|
|
||||||
script := sshSetupScript(c.SSHPassword, true)
|
script := sshSetupScript(true)
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
@@ -1638,6 +1770,9 @@ func (m *Manager) EnsureSSH(id int) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to configure SSH in container %d: %v, output: %s", id, err, string(output))
|
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 c.IP == "" {
|
||||||
if ip, ipErr := m.GetContainerIP(lxcName); ipErr == nil && ip != "" {
|
if ip, ipErr := m.GetContainerIP(lxcName); ipErr == nil && ip != "" {
|
||||||
@@ -1656,13 +1791,13 @@ func (m *Manager) EnsureSSH(id int) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) quickEnsureSSHPassword(lxcName, password string) error {
|
func (m *Manager) quickEnsureSSHPassword(lxcName, password string) error {
|
||||||
if password == "" {
|
if err := validateRootPassword(password); err != nil {
|
||||||
return fmt.Errorf("empty SSH password")
|
return err
|
||||||
}
|
}
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
cmd := exec.CommandContext(ctx, "lxc-attach", "-n", lxcName, "--", "sh", "-c",
|
cmd := exec.CommandContext(ctx, "lxc-attach", "-n", lxcName, "--", "chpasswd")
|
||||||
fmt.Sprintf("printf '%%s:%%s\\n' root %s | chpasswd", shellQuote(password)))
|
cmd.Stdin = strings.NewReader(rootPasswordInput(password))
|
||||||
output, err := cmd.CombinedOutput()
|
output, err := cmd.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to update SSH password quickly: %v, output: %s", err, string(output))
|
return fmt.Errorf("failed to update SSH password quickly: %v, output: %s", err, string(output))
|
||||||
@@ -1670,6 +1805,20 @@ func (m *Manager) quickEnsureSSHPassword(lxcName, password string) error {
|
|||||||
return nil
|
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 {
|
func (m *Manager) containerPortListening(lxcName string, port int) bool {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
@@ -1677,9 +1826,8 @@ func (m *Manager) containerPortListening(lxcName string, port int) bool {
|
|||||||
return exec.CommandContext(ctx, "lxc-attach", "-n", lxcName, "--", "sh", "-c", check).Run() == nil
|
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
|
script := `set -u
|
||||||
ROOT_PASSWORD=` + shellQuote(password) + `
|
|
||||||
|
|
||||||
# DNS setup: handle both traditional /etc/resolv.conf and systemd-resolved (Ubuntu 24.04).
|
# 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.
|
# On modern distros, /etc/resolv.conf is a symlink managed by systemd-resolved.
|
||||||
@@ -1760,6 +1908,11 @@ install_sshd() {
|
|||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ensure_sshd_runtime_dir() {
|
||||||
|
mkdir -p /run/sshd /var/run/sshd
|
||||||
|
chmod 0755 /run/sshd /var/run/sshd 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
set_sshd_option() {
|
set_sshd_option() {
|
||||||
key="$1"
|
key="$1"
|
||||||
value="$2"
|
value="$2"
|
||||||
@@ -1786,11 +1939,13 @@ set_sshd_option() {
|
|||||||
|
|
||||||
install_sshd || exit 30
|
install_sshd || exit 30
|
||||||
|
|
||||||
mkdir -p /run/sshd /var/run/sshd /etc/ssh /etc/ssh/sshd_config.d
|
mkdir -p /etc/ssh /etc/ssh/sshd_config.d
|
||||||
|
ensure_sshd_runtime_dir
|
||||||
ssh-keygen -A >/dev/null 2>&1 || true
|
ssh-keygen -A >/dev/null 2>&1 || true
|
||||||
|
|
||||||
cat >/etc/ssh/sshd_config.d/99-clicd.conf <<'EOF'
|
cat >/etc/ssh/sshd_config.d/99-clicd.conf <<'EOF'
|
||||||
PermitRootLogin yes
|
PermitRootLogin yes
|
||||||
|
PubkeyAuthentication yes
|
||||||
PasswordAuthentication yes
|
PasswordAuthentication yes
|
||||||
KbdInteractiveAuthentication no
|
KbdInteractiveAuthentication no
|
||||||
ChallengeResponseAuthentication no
|
ChallengeResponseAuthentication no
|
||||||
@@ -1798,16 +1953,12 @@ UsePAM no
|
|||||||
EOF
|
EOF
|
||||||
|
|
||||||
set_sshd_option PermitRootLogin yes
|
set_sshd_option PermitRootLogin yes
|
||||||
|
set_sshd_option PubkeyAuthentication yes
|
||||||
set_sshd_option PasswordAuthentication yes
|
set_sshd_option PasswordAuthentication yes
|
||||||
set_sshd_option KbdInteractiveAuthentication no
|
set_sshd_option KbdInteractiveAuthentication no
|
||||||
set_sshd_option ChallengeResponseAuthentication no
|
set_sshd_option ChallengeResponseAuthentication no
|
||||||
set_sshd_option UsePAM 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
|
if command -v rc-update >/dev/null 2>&1; then
|
||||||
rc-update add sshd default >/dev/null 2>&1 || true
|
rc-update add sshd default >/dev/null 2>&1 || true
|
||||||
fi
|
fi
|
||||||
@@ -1824,6 +1975,7 @@ if command -v chkconfig >/dev/null 2>&1; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
SSHD_BIN="$(sshd_path)" || exit 32
|
SSHD_BIN="$(sshd_path)" || exit 32
|
||||||
|
ensure_sshd_runtime_dir
|
||||||
"$SSHD_BIN" -t -f /etc/ssh/sshd_config >/tmp/clicd-sshd-test.log 2>&1 || {
|
"$SSHD_BIN" -t -f /etc/ssh/sshd_config >/tmp/clicd-sshd-test.log 2>&1 || {
|
||||||
cat /tmp/clicd-sshd-test.log
|
cat /tmp/clicd-sshd-test.log
|
||||||
exit 32
|
exit 32
|
||||||
@@ -1838,6 +1990,7 @@ if command -v systemctl >/dev/null 2>&1; then
|
|||||||
systemctl stop ssh.socket 2>/dev/null || true
|
systemctl stop ssh.socket 2>/dev/null || true
|
||||||
systemctl disable ssh.socket 2>/dev/null || true
|
systemctl disable ssh.socket 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
|
ensure_sshd_runtime_dir
|
||||||
if command -v systemctl >/dev/null 2>&1 && [ -d /run/systemd/system ]; then
|
if command -v systemctl >/dev/null 2>&1 && [ -d /run/systemd/system ]; then
|
||||||
systemctl restart ssh >/dev/null 2>&1 || systemctl restart sshd >/dev/null 2>&1 || true
|
systemctl restart ssh >/dev/null 2>&1 || systemctl restart sshd >/dev/null 2>&1 || true
|
||||||
fi
|
fi
|
||||||
@@ -1848,9 +2001,22 @@ service ssh restart >/dev/null 2>&1 ||
|
|||||||
/etc/init.d/sshd restart >/dev/null 2>&1 ||
|
/etc/init.d/sshd restart >/dev/null 2>&1 ||
|
||||||
true
|
true
|
||||||
|
|
||||||
|
ensure_sshd_runtime_dir
|
||||||
|
|
||||||
|
for i in 1 2 3 4 5; do
|
||||||
|
if (ss -ltn 2>/dev/null || netstat -tln 2>/dev/null) | grep -Eq '(^|[[:space:]])[^[:space:]]*:22[[:space:]]'; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if pgrep -x sshd >/dev/null 2>&1; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
if ! (ss -ltn 2>/dev/null || netstat -tln 2>/dev/null) | grep -Eq '(^|[[:space:]])[^[:space:]]*:22[[:space:]]'; then
|
if ! (ss -ltn 2>/dev/null || netstat -tln 2>/dev/null) | grep -Eq '(^|[[:space:]])[^[:space:]]*:22[[:space:]]'; then
|
||||||
pkill -x sshd >/dev/null 2>&1 || killall sshd >/dev/null 2>&1 || true
|
pkill -x sshd >/dev/null 2>&1 || killall sshd >/dev/null 2>&1 || true
|
||||||
rm -f /run/sshd.pid /var/run/sshd.pid
|
rm -f /run/sshd.pid /var/run/sshd.pid
|
||||||
|
ensure_sshd_runtime_dir
|
||||||
"$SSHD_BIN" -f /etc/ssh/sshd_config >/dev/null 2>&1 || exit 32
|
"$SSHD_BIN" -f /etc/ssh/sshd_config >/dev/null 2>&1 || exit 32
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -1888,16 +2054,11 @@ func (m *Manager) ResetSSHPassword(id int, password string) (string, error) {
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
rootfsPath := filepath.Join(m.LxcPath, lxcName, "rootfs")
|
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)
|
return "", fmt.Errorf("failed to configure SSH: %v", err)
|
||||||
}
|
}
|
||||||
cmd, err := m.rootfsCommand(rootfsPath, "sh", "-c", fmt.Sprintf("printf '%%s:%%s\\n' root %s | chpasswd", shellQuote(newPassword)))
|
if err := m.setRootfsPassword(rootfsPath, newPassword); err != nil {
|
||||||
if err != nil {
|
return "", fmt.Errorf("failed to set password: %v", err)
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
output, err := cmd.CombinedOutput()
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to set password: %v, output: %s", err, string(output))
|
|
||||||
}
|
}
|
||||||
c.SSHPassword = newPassword
|
c.SSHPassword = newPassword
|
||||||
config.SaveConfig()
|
config.SaveConfig()
|
||||||
@@ -1911,6 +2072,10 @@ func (m *Manager) rootfsCommand(rootfsPath string, args ...string) (*exec.Cmd, e
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
safeArgs, err := safeRootfsCommandArgs(args)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
marker := filepath.Join(cleanRootfsPath, ".clicd-unprivileged-shifted")
|
marker := filepath.Join(cleanRootfsPath, ".clicd-unprivileged-shifted")
|
||||||
if _, err := os.Stat(marker); err == nil {
|
if _, err := os.Stat(marker); err == nil {
|
||||||
@@ -1921,11 +2086,11 @@ func (m *Manager) rootfsCommand(rootfsPath string, args ...string) (*exec.Cmd, e
|
|||||||
"-m", fmt.Sprintf("g:0:%d:65536", gidBase),
|
"-m", fmt.Sprintf("g:0:%d:65536", gidBase),
|
||||||
"--", "chroot", "--", cleanRootfsPath,
|
"--", "chroot", "--", cleanRootfsPath,
|
||||||
}
|
}
|
||||||
cmdArgs = append(cmdArgs, args...)
|
cmdArgs = append(cmdArgs, safeArgs...)
|
||||||
return exec.Command("lxc-usernsexec", cmdArgs...), nil
|
return exec.Command("lxc-usernsexec", cmdArgs...), nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
cmdArgs := append([]string{"--", cleanRootfsPath}, args...)
|
cmdArgs := append([]string{"--", cleanRootfsPath}, safeArgs...)
|
||||||
return exec.Command("chroot", cmdArgs...), nil
|
return exec.Command("chroot", cmdArgs...), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1937,6 +2102,97 @@ func (m *Manager) runRootfsCommand(rootfsPath string, args ...string) error {
|
|||||||
return cmd.Run()
|
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 (m *Manager) installRootAuthorizedKey(rootfsPath, publicKey string) error {
|
||||||
|
key, err := NormalizeSSHPublicKey(publicKey)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if key == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
sshDir := filepath.Join(rootfsPath, "root", ".ssh")
|
||||||
|
if err := os.MkdirAll(sshDir, 0700); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
authPath := filepath.Join(sshDir, "authorized_keys")
|
||||||
|
existing, _ := os.ReadFile(authPath)
|
||||||
|
lines := strings.Split(string(existing), "\n")
|
||||||
|
for _, line := range lines {
|
||||||
|
if strings.TrimSpace(line) == key {
|
||||||
|
_ = os.Chmod(sshDir, 0700)
|
||||||
|
_ = os.Chmod(authPath, 0600)
|
||||||
|
_ = os.Chown(sshDir, 0, 0)
|
||||||
|
_ = os.Chown(authPath, 0, 0)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
content := strings.TrimRight(string(existing), "\r\n")
|
||||||
|
if content != "" {
|
||||||
|
content += "\n"
|
||||||
|
}
|
||||||
|
content += key + "\n"
|
||||||
|
if err := os.WriteFile(authPath, []byte(content), 0600); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_ = os.Chmod(sshDir, 0700)
|
||||||
|
_ = os.Chmod(authPath, 0600)
|
||||||
|
_ = os.Chown(sshDir, 0, 0)
|
||||||
|
_ = os.Chown(authPath, 0, 0)
|
||||||
|
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) {
|
func (m *Manager) safeRootfsPath(rootfsPath string) (string, error) {
|
||||||
if rootfsPath == "" {
|
if rootfsPath == "" {
|
||||||
return "", fmt.Errorf("empty rootfs path")
|
return "", fmt.Errorf("empty rootfs path")
|
||||||
@@ -1969,6 +2225,13 @@ func (m *Manager) safeRootfsPath(rootfsPath string) (string, error) {
|
|||||||
if rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) || filepath.IsAbs(rel) {
|
if rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) || filepath.IsAbs(rel) {
|
||||||
return "", fmt.Errorf("refusing unsafe rootfs path: %s", cleanRootfsPath)
|
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
|
return cleanRootfsPath, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2211,8 +2474,87 @@ func (m *Manager) ImportExistingClicdContainers() ([]config.Container, error) {
|
|||||||
return imported, nil
|
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
|
// ReinstallContainer reinstalls the container OS
|
||||||
func (m *Manager) ReinstallContainer(id int, templateID string) error {
|
func (m *Manager) ReinstallContainer(id int, templateID string, authConfig ...ContainerConfig) error {
|
||||||
c := config.FindContainer(id)
|
c := config.FindContainer(id)
|
||||||
if c == nil {
|
if c == nil {
|
||||||
return fmt.Errorf("container not found: %d", id)
|
return fmt.Errorf("container not found: %d", id)
|
||||||
@@ -2222,6 +2564,14 @@ func (m *Manager) ReinstallContainer(id int, templateID string) error {
|
|||||||
if tmpl == nil {
|
if tmpl == nil {
|
||||||
return fmt.Errorf("template not found: %s", templateID)
|
return fmt.Errorf("template not found: %s", templateID)
|
||||||
}
|
}
|
||||||
|
authCfg := ContainerConfig{SSHAuthMode: SSHAuthKeep}
|
||||||
|
if len(authConfig) > 0 {
|
||||||
|
authCfg = authConfig[0]
|
||||||
|
}
|
||||||
|
sshAccess, err := ResolveReinstallSSHAccess(c.SSHPassword, authCfg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
lxcName := c.LxcName()
|
lxcName := c.LxcName()
|
||||||
|
|
||||||
@@ -2234,26 +2584,10 @@ func (m *Manager) ReinstallContainer(id int, templateID string) error {
|
|||||||
// Clean port mappings temporarily
|
// Clean port mappings temporarily
|
||||||
m.CleanPortMappings(id)
|
m.CleanPortMappings(id)
|
||||||
|
|
||||||
// Destroy old LXC but keep config
|
// Download the new OS into a temporary container, then replace only the
|
||||||
exec.Command("lxc-stop", "-n", lxcName, "-k").Run()
|
// existing rootfs. The target container directory and config are preserved.
|
||||||
exec.Command("lxc-destroy", "-n", lxcName, "-f").Run()
|
if err := m.replaceRootfsFromTemplate(lxcName, tmpl); err != nil {
|
||||||
rootfs := filepath.Join(m.LxcPath, lxcName, "rootfs")
|
return err
|
||||||
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))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := m.applyDiskLimit(lxcName, c.DiskGB); err != nil {
|
if err := m.applyDiskLimit(lxcName, c.DiskGB); err != nil {
|
||||||
@@ -2270,14 +2604,15 @@ func (m *Manager) ReinstallContainer(id int, templateID string) error {
|
|||||||
NetworkBWMbps: c.NetworkBWMbps,
|
NetworkBWMbps: c.NetworkBWMbps,
|
||||||
MonthlyTrafficGB: c.MonthlyTrafficGB,
|
MonthlyTrafficGB: c.MonthlyTrafficGB,
|
||||||
IOSpeedMBps: c.IOSpeedMBps,
|
IOSpeedMBps: c.IOSpeedMBps,
|
||||||
AssignIPv6: c.IPv6 != "",
|
AssignIPv6: c.IPv6 != "" || len(c.IPv6Addresses) > 0,
|
||||||
ExpiresAt: c.ExpiresAt,
|
ExpiresAt: c.ExpiresAt,
|
||||||
}
|
}
|
||||||
if err := m.applyResourceLimits(lxcName, cfg); err != nil {
|
if err := m.applyResourceLimits(lxcName, cfg); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if c.IPv6 != "" {
|
if c.IPv6 != "" || len(c.IPv6Addresses) > 0 {
|
||||||
if err := m.applyIPv6Config(lxcName, c.IPv6); err != nil {
|
c.NormalizeNetworkAssignments()
|
||||||
|
if err := m.applyIPv6Config(lxcName, c.IPv6AddressStrings()...); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2285,22 +2620,24 @@ func (m *Manager) ReinstallContainer(id int, templateID string) error {
|
|||||||
// Set root password and pre-configure network/SSH via chroot.
|
// Set root password and pre-configure network/SSH via chroot.
|
||||||
rootfsPath := filepath.Join(m.LxcPath, lxcName, "rootfs")
|
rootfsPath := filepath.Join(m.LxcPath, lxcName, "rootfs")
|
||||||
m.preconfigureNetwork(rootfsPath, templateID)
|
m.preconfigureNetwork(rootfsPath, templateID)
|
||||||
if c.IPv6 != "" {
|
if c.IPv6 != "" || len(c.IPv6Addresses) > 0 {
|
||||||
if err := installContainerIPv6Init(rootfsPath, c.IPv6); err != nil {
|
if err := installContainerIPv6Init(rootfsPath, c.IPv6AddressStrings()...); err != nil {
|
||||||
fmt.Printf("Warning: failed to install IPv6 init in %s after reinstall: %v\n", lxcName, err)
|
fmt.Printf("Warning: failed to install IPv6 init in %s after reinstall: %v\n", lxcName, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if c.SSHPassword == "" {
|
c.SSHPassword = sshAccess.Password
|
||||||
c.SSHPassword = generateRandomString(16)
|
if err := m.preconfigureSSH(rootfsPath, templateID); err != nil {
|
||||||
}
|
|
||||||
if err := m.preconfigureSSH(rootfsPath, c.SSHPassword, templateID); err != nil {
|
|
||||||
fmt.Printf("Warning: failed to pre-configure SSH in %s after reinstall: %v\n", lxcName, err)
|
fmt.Printf("Warning: failed to pre-configure SSH in %s after reinstall: %v\n", lxcName, err)
|
||||||
}
|
}
|
||||||
|
if sshAccess.PublicKey != "" {
|
||||||
|
if err := m.installRootAuthorizedKey(rootfsPath, sshAccess.PublicKey); err != nil {
|
||||||
|
return fmt.Errorf("failed to install SSH public key: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
if err := m.shiftRootfsForUnprivileged(lxcName); err != nil {
|
if err := m.shiftRootfsForUnprivileged(lxcName); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := m.runRootfsCommand(rootfsPath,
|
if err := m.setRootfsPassword(rootfsPath, c.SSHPassword); err != nil {
|
||||||
"sh", "-c", fmt.Sprintf("printf '%%s:%%s\\n' root %s | chpasswd", shellQuote(c.SSHPassword))); err != nil {
|
|
||||||
fmt.Printf("Warning: failed to set root password in %s after reinstall: %v\n", lxcName, err)
|
fmt.Printf("Warning: failed to set root password in %s after reinstall: %v\n", lxcName, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2316,14 +2653,17 @@ func (m *Manager) ReinstallContainer(id int, templateID string) error {
|
|||||||
config.SaveConfig()
|
config.SaveConfig()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
logFile := filepath.Join(os.TempDir(), "clicd-"+lxcName+"-start.log")
|
logFile, consoleLog, output, err := m.startLXCContainerDaemon(lxcName)
|
||||||
os.Remove(logFile)
|
if err != nil {
|
||||||
startCmd := exec.Command("lxc-start", "-n", lxcName, "-d", "--logfile", logFile, "--logpriority", "DEBUG")
|
|
||||||
if output, err := startCmd.CombinedOutput(); err != nil {
|
|
||||||
fmt.Printf("Warning: failed to start container after reinstall: %v\n", err)
|
fmt.Printf("Warning: failed to start container after reinstall: %v\n", err)
|
||||||
c.Status = "stopped"
|
c.Status = "stopped"
|
||||||
config.SaveConfig()
|
config.SaveConfig()
|
||||||
return fmt.Errorf("reinstalled but failed to start: %v, output: %s, lxc log: %s", err, string(output), tailFile(logFile, 80))
|
return fmt.Errorf("reinstalled but failed to start: %v, output: %s, lxc log: %s, console: %s", err, string(output), tailFile(logFile, 80), tailFile(consoleLog, 80))
|
||||||
|
}
|
||||||
|
if err := m.waitForLXCStartup(lxcName, logFile, consoleLog); err != nil {
|
||||||
|
c.Status = "stopped"
|
||||||
|
config.SaveConfig()
|
||||||
|
return fmt.Errorf("reinstalled but container did not stay running: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for network and install SSH
|
// Wait for network and install SSH
|
||||||
@@ -2349,7 +2689,7 @@ func (m *Manager) ReinstallContainer(id int, templateID string) error {
|
|||||||
if c.NetworkBWMbps > 0 {
|
if c.NetworkBWMbps > 0 {
|
||||||
m.applyBandwidthLimit(c.LxcName(), c.NetworkBWMbps)
|
m.applyBandwidthLimit(c.LxcName(), c.NetworkBWMbps)
|
||||||
}
|
}
|
||||||
if c.IPv6 != "" {
|
if c.IPv6 != "" || len(c.IPv6Addresses) > 0 {
|
||||||
if err := m.ApplyIPv6(id); err != nil {
|
if err := m.ApplyIPv6(id); err != nil {
|
||||||
fmt.Printf("Warning: failed to apply IPv6 after reinstall: %v\n", err)
|
fmt.Printf("Warning: failed to apply IPv6 after reinstall: %v\n", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestRootfsCommandAddsSeparatorAndPreservesArgs(t *testing.T) {
|
func TestRootfsCommandAddsSeparatorForAllowedCommand(t *testing.T) {
|
||||||
base := t.TempDir()
|
base := t.TempDir()
|
||||||
rootfs := filepath.Join(base, "ct-1", "rootfs")
|
rootfs := filepath.Join(base, "ct-1", "rootfs")
|
||||||
if err := os.MkdirAll(rootfs, 0755); err != nil {
|
if err := os.MkdirAll(rootfs, 0755); err != nil {
|
||||||
@@ -16,18 +16,31 @@ func TestRootfsCommandAddsSeparatorAndPreservesArgs(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
m := &Manager{LxcPath: base}
|
m := &Manager{LxcPath: base}
|
||||||
cmd, err := m.rootfsCommand(rootfs, "sh", "-c", "true", "--flag")
|
cmd, err := m.rootfsCommand(rootfs, "chpasswd")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("rootfsCommand returned error: %v", err)
|
t.Fatalf("rootfsCommand returned error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
want := []string{"chroot", "--", rootfs, "sh", "-c", "true", "--flag"}
|
want := []string{"chroot", "--", rootfs, "chpasswd"}
|
||||||
if !reflect.DeepEqual(cmd.Args, want) {
|
if !reflect.DeepEqual(cmd.Args, want) {
|
||||||
t.Fatalf("cmd.Args = %#v, want %#v", cmd.Args, want)
|
t.Fatalf("cmd.Args = %#v, want %#v", cmd.Args, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRootfsCommandAllowsLeadingDashContainerName(t *testing.T) {
|
func TestRootfsCommandRejectsUnmanagedCommand(t *testing.T) {
|
||||||
|
base := t.TempDir()
|
||||||
|
rootfs := filepath.Join(base, "ct-1", "rootfs")
|
||||||
|
if err := os.MkdirAll(rootfs, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
m := &Manager{LxcPath: base}
|
||||||
|
if _, err := m.rootfsCommand(rootfs, "true"); err == nil {
|
||||||
|
t.Fatal("rootfsCommand allowed unmanaged command")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRootfsCommandRejectsLeadingDashContainerName(t *testing.T) {
|
||||||
base := t.TempDir()
|
base := t.TempDir()
|
||||||
rootfs := filepath.Join(base, "-ct", "rootfs")
|
rootfs := filepath.Join(base, "-ct", "rootfs")
|
||||||
if err := os.MkdirAll(rootfs, 0755); err != nil {
|
if err := os.MkdirAll(rootfs, 0755); err != nil {
|
||||||
@@ -35,14 +48,8 @@ func TestRootfsCommandAllowsLeadingDashContainerName(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
m := &Manager{LxcPath: base}
|
m := &Manager{LxcPath: base}
|
||||||
cmd, err := m.rootfsCommand(rootfs, "true")
|
if _, err := m.rootfsCommand(rootfs, "chpasswd"); err == nil {
|
||||||
if err != nil {
|
t.Fatal("rootfsCommand allowed leading-dash container name")
|
||||||
t.Fatalf("rootfsCommand returned error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
want := []string{"chroot", "--", rootfs, "true"}
|
|
||||||
if !reflect.DeepEqual(cmd.Args, want) {
|
|
||||||
t.Fatalf("cmd.Args = %#v, want %#v", cmd.Args, want)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,7 +71,7 @@ func TestRootfsCommandRejectsUnsafeRootfsPaths(t *testing.T) {
|
|||||||
|
|
||||||
for _, tc := range tests {
|
for _, tc := range tests {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
if _, err := m.rootfsCommand(tc.path, "true"); err == nil {
|
if _, err := m.rootfsCommand(tc.path, "chpasswd"); err == nil {
|
||||||
t.Fatalf("rootfsCommand(%q) returned nil error", tc.path)
|
t.Fatalf("rootfsCommand(%q) returned nil error", tc.path)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
+326
-26
@@ -2,8 +2,10 @@ package lxc
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/netip"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"clicd/internal/config"
|
"clicd/internal/config"
|
||||||
)
|
)
|
||||||
@@ -17,6 +19,7 @@ func (m *Manager) ApplyPortMappings(id int) error {
|
|||||||
if c.IP == "" {
|
if c.IP == "" {
|
||||||
return fmt.Errorf("container has no IP")
|
return fmt.Errorf("container has no IP")
|
||||||
}
|
}
|
||||||
|
EnsureAssignedPublicIPv4s(c.PublicIPv4s)
|
||||||
tag := clicdTag(id)
|
tag := clicdTag(id)
|
||||||
bridge := "lxcbr0"
|
bridge := "lxcbr0"
|
||||||
subnet := "10.0.3.0/24"
|
subnet := "10.0.3.0/24"
|
||||||
@@ -27,35 +30,180 @@ func (m *Manager) ApplyPortMappings(id int) error {
|
|||||||
|
|
||||||
EnsureForwardRules(bridge)
|
EnsureForwardRules(bridge)
|
||||||
m.CleanPortMappings(id)
|
m.CleanPortMappings(id)
|
||||||
|
deleteBridgeMasquerade(subnet)
|
||||||
|
|
||||||
for _, pm := range c.PortMappings {
|
for _, pm := range c.PortMappings {
|
||||||
cmd := exec.Command("iptables",
|
for _, hostIP := range expandPortMappingHostIPs(c, pm) {
|
||||||
"-t", "nat",
|
args := []string{
|
||||||
"-I", "PREROUTING", "1",
|
"-t", "nat",
|
||||||
"-p", pm.Protocol,
|
"-I", "PREROUTING", "1",
|
||||||
"--dport", fmt.Sprintf("%d", pm.HostPort),
|
"-p", pm.Protocol,
|
||||||
"-j", "DNAT",
|
}
|
||||||
"--to-destination", fmt.Sprintf("%s:%d", c.IP, pm.ContainerPort),
|
if hostIP != "" {
|
||||||
"-m", "comment", "--comment", fmt.Sprintf("clicd-%s-%d", tag, pm.HostPort),
|
args = append(args, "-d", hostIP)
|
||||||
)
|
}
|
||||||
output, err := cmd.CombinedOutput()
|
args = append(args,
|
||||||
if err != nil {
|
"--dport", fmt.Sprintf("%d", pm.HostPort),
|
||||||
fmt.Printf("Warning: failed to apply port mapping %d->%s:%d: %v, output: %s\n",
|
"-j", "DNAT",
|
||||||
pm.HostPort, c.IP, pm.ContainerPort, err, string(output))
|
"--to-destination", fmt.Sprintf("%s:%d", c.IP, pm.ContainerPort),
|
||||||
continue
|
"-m", "comment", "--comment", fmt.Sprintf("clicd-%s-%s-%d", tag, natRuleIPTag(hostIP), pm.HostPort),
|
||||||
|
)
|
||||||
|
cmd := exec.Command("iptables", args...)
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Warning: failed to apply port mapping %s:%d->%s:%d: %v, output: %s\n",
|
||||||
|
displayHostIP(hostIP), pm.HostPort, c.IP, pm.ContainerPort, err, string(output))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fmt.Printf("Port mapping: %s:%d -> %s:%d\n", displayHostIP(hostIP), pm.HostPort, c.IP, pm.ContainerPort)
|
||||||
}
|
}
|
||||||
fmt.Printf("Port mapping: host:%d -> %s:%d\n", pm.HostPort, c.IP, pm.ContainerPort)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if exec.Command("iptables", "-t", "nat", "-C", "POSTROUTING", "-s", subnet, "-o", "eth+", "-j", "MASQUERADE").Run() != nil {
|
applyIPv4EgressPolicy(c, bridge, subnet, tag)
|
||||||
exec.Command("iptables", "-t", "nat", "-I", "POSTROUTING", "1", "-s", subnet, "-o", "eth+", "-j", "MASQUERADE").Run()
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func applyIPv4EgressPolicy(c *config.Container, bridge, subnet, tag string) {
|
||||||
|
if c == nil || strings.TrimSpace(c.IP) == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if containerAllowsPublicIPv4Egress(c) {
|
||||||
|
if _, ok := primaryPublicIPv4Assignment(c); ok {
|
||||||
|
applyPublicIPv4SNAT(c, tag)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ensureContainerMasquerade(c, tag)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ensureIPv4EgressBlocked(c, bridge, subnet, tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
func containerAllowsPublicIPv4Egress(c *config.Container) bool {
|
||||||
|
if c == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if len(c.PublicIPv4s) > 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return c.PortMappingLimit > 0 || len(c.PortMappings) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureContainerMasquerade(c *config.Container, tag string) {
|
||||||
|
args := []string{
|
||||||
|
"-s", c.IP + "/32",
|
||||||
|
"-m", "comment", "--comment", fmt.Sprintf("clicd-%s-masq", tag),
|
||||||
|
"-j", "MASQUERADE",
|
||||||
|
}
|
||||||
|
if host := DetectPublicIPv4(); strings.TrimSpace(host.Interface) != "" {
|
||||||
|
args = append([]string{"-o", strings.TrimSpace(host.Interface)}, args...)
|
||||||
|
} else {
|
||||||
|
args = append([]string{"-o", "eth+"}, args...)
|
||||||
|
}
|
||||||
|
ensureNATRule("POSTROUTING", args)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureIPv4EgressBlocked(c *config.Container, bridge, subnet, tag string) {
|
||||||
|
args := []string{
|
||||||
|
"-i", bridge,
|
||||||
|
"-s", c.IP + "/32",
|
||||||
|
"!", "-d", subnet,
|
||||||
|
"-m", "comment", "--comment", fmt.Sprintf("clicd-%s-v4-egress-block", tag),
|
||||||
|
"-j", "REJECT",
|
||||||
|
}
|
||||||
|
ensureFilterRule("FORWARD", args)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureNATRule(chain string, args []string) {
|
||||||
|
check := append([]string{"-t", "nat", "-C", chain}, args...)
|
||||||
|
if exec.Command("iptables", check...).Run() == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
add := append([]string{"-t", "nat", "-I", chain, "1"}, args...)
|
||||||
|
exec.Command("iptables", add...).Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureFilterRule(chain string, args []string) {
|
||||||
|
check := append([]string{"-C", chain}, args...)
|
||||||
|
if exec.Command("iptables", check...).Run() == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
add := append([]string{"-I", chain, "1"}, args...)
|
||||||
|
exec.Command("iptables", add...).Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteBridgeMasquerade(subnet string) {
|
||||||
|
for exec.Command("iptables", "-t", "nat", "-D", "POSTROUTING", "-s", subnet, "-o", "eth+", "-j", "MASQUERADE").Run() == nil {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyPublicIPv4SNAT(c *config.Container, tag string) {
|
||||||
|
if c == nil || strings.TrimSpace(c.IP) == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
assignment, ok := primaryPublicIPv4Assignment(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hostIP := strings.TrimSpace(assignment.Address)
|
||||||
|
if hostIP == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
iface := strings.TrimSpace(assignment.Interface)
|
||||||
|
if iface == "" {
|
||||||
|
if info, ok := publicIPv4InfoByAddress(hostIP); ok {
|
||||||
|
iface = strings.TrimSpace(info.Interface)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if iface == "" {
|
||||||
|
if host := DetectPublicIPv4(); host.Interface != "" {
|
||||||
|
iface = host.Interface
|
||||||
|
}
|
||||||
|
}
|
||||||
|
args := []string{
|
||||||
|
"-t", "nat",
|
||||||
|
"-I", "POSTROUTING", "1",
|
||||||
|
"-s", c.IP + "/32",
|
||||||
|
}
|
||||||
|
if iface != "" {
|
||||||
|
args = append(args, "-o", iface)
|
||||||
|
}
|
||||||
|
args = append(args,
|
||||||
|
"-m", "comment", "--comment", fmt.Sprintf("clicd-%s-snat-%s", tag, natRuleIPTag(hostIP)),
|
||||||
|
"-j", "SNAT", "--to-source", hostIP,
|
||||||
|
)
|
||||||
|
if output, err := exec.Command("iptables", args...).CombinedOutput(); err != nil {
|
||||||
|
fmt.Printf("Warning: failed to apply public IPv4 SNAT %s -> %s: %v, output: %s\n", c.IP, hostIP, err, string(output))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func primaryPublicIPv4Assignment(c *config.Container) (config.PublicIPv4Assignment, bool) {
|
||||||
|
if c == nil {
|
||||||
|
return config.PublicIPv4Assignment{}, false
|
||||||
|
}
|
||||||
|
for _, item := range c.PublicIPv4s {
|
||||||
|
if strings.TrimSpace(item.Address) != "" {
|
||||||
|
return item, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return config.PublicIPv4Assignment{}, false
|
||||||
|
}
|
||||||
|
|
||||||
func clicdTag(id int) string { return "c" + strconv.Itoa(id) }
|
func clicdTag(id int) string { return "c" + strconv.Itoa(id) }
|
||||||
|
|
||||||
|
func EnsureAllRunningPortMappings() {
|
||||||
|
m := NewManager()
|
||||||
|
for i := range config.AppConfig.Containers {
|
||||||
|
c := &config.AppConfig.Containers[i]
|
||||||
|
if c.Status != "running" || strings.TrimSpace(c.IP) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := m.ApplyPortMappings(c.ID); err != nil {
|
||||||
|
fmt.Printf("Warning: failed to restore port mappings for %s: %v\n", c.Name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// EnsureForwardRules makes sure iptables FORWARD chain allows bridge traffic.
|
// EnsureForwardRules makes sure iptables FORWARD chain allows bridge traffic.
|
||||||
func EnsureForwardRules(bridge string) {
|
func EnsureForwardRules(bridge string) {
|
||||||
if bridge == "" {
|
if bridge == "" {
|
||||||
@@ -81,8 +229,13 @@ func EnsureForwardRules(bridge string) {
|
|||||||
// CleanPortMappings removes all iptables rules for a container
|
// CleanPortMappings removes all iptables rules for a container
|
||||||
func (m *Manager) CleanPortMappings(id int) error {
|
func (m *Manager) CleanPortMappings(id int) error {
|
||||||
tag := clicdTag(id)
|
tag := clicdTag(id)
|
||||||
|
for _, chain := range []string{"PREROUTING", "POSTROUTING"} {
|
||||||
|
cmd := exec.Command("sh", "-c",
|
||||||
|
fmt.Sprintf("iptables -t nat -L %s -n --line-numbers 2>/dev/null | grep 'clicd-%s-' | awk '{print $1}' | sort -rn | while read num; do iptables -t nat -D %s $num; done", chain, tag, chain))
|
||||||
|
cmd.Run()
|
||||||
|
}
|
||||||
cmd := exec.Command("sh", "-c",
|
cmd := exec.Command("sh", "-c",
|
||||||
fmt.Sprintf("iptables -t nat -L PREROUTING -n --line-numbers 2>/dev/null | grep 'clicd-%s' | awk '{print $1}' | sort -rn | while read num; do iptables -t nat -D PREROUTING $num; done", tag))
|
fmt.Sprintf("iptables -S FORWARD 2>/dev/null | grep 'clicd-%s-' | sed 's/^-A /-D /' | while read rule; do iptables $rule; done", tag))
|
||||||
cmd.Run()
|
cmd.Run()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -94,12 +247,26 @@ func SetupDefaultPortMappings(sshPort int) []config.PortMapping {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func DefaultPortMappingHostIP(assignments []config.PublicIPv4Assignment) string {
|
||||||
|
if len(assignments) == 1 {
|
||||||
|
return strings.TrimSpace(assignments[0].Address)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultPortMappingHostIP(assignments []config.PublicIPv4Assignment) string {
|
||||||
|
return DefaultPortMappingHostIP(assignments)
|
||||||
|
}
|
||||||
|
|
||||||
// AddPortMapping adds a NAT rule to a container
|
// AddPortMapping adds a NAT rule to a container
|
||||||
func (m *Manager) AddPortMapping(id int, pm config.PortMapping) ([]config.PortMapping, error) {
|
func (m *Manager) AddPortMapping(id int, pm config.PortMapping) ([]config.PortMapping, error) {
|
||||||
c := config.FindContainer(id)
|
c := config.FindContainer(id)
|
||||||
if c == nil {
|
if c == nil {
|
||||||
return nil, fmt.Errorf("container not found: %d", id)
|
return nil, fmt.Errorf("container not found: %d", id)
|
||||||
}
|
}
|
||||||
|
if c.PortMappingLimit <= 0 {
|
||||||
|
return nil, fmt.Errorf("container has no IPv4 NAT port quota")
|
||||||
|
}
|
||||||
if c.PortMappingLimit > 0 && len(c.PortMappings) >= c.PortMappingLimit {
|
if c.PortMappingLimit > 0 && len(c.PortMappings) >= c.PortMappingLimit {
|
||||||
return nil, fmt.Errorf("port mapping quota exceeded: %d/%d", len(c.PortMappings), c.PortMappingLimit)
|
return nil, fmt.Errorf("port mapping quota exceeded: %d/%d", len(c.PortMappings), c.PortMappingLimit)
|
||||||
}
|
}
|
||||||
@@ -168,6 +335,17 @@ func normalizePortMapping(c *config.Container, skipIndex int, pm config.PortMapp
|
|||||||
if pm.Protocol == "" {
|
if pm.Protocol == "" {
|
||||||
pm.Protocol = "tcp"
|
pm.Protocol = "tcp"
|
||||||
}
|
}
|
||||||
|
pm.Protocol = strings.ToLower(strings.TrimSpace(pm.Protocol))
|
||||||
|
pm.HostIP = strings.TrimSpace(pm.HostIP)
|
||||||
|
if pm.HostIP != "" {
|
||||||
|
addr, err := netip.ParseAddr(pm.HostIP)
|
||||||
|
if err != nil || !addr.Is4() {
|
||||||
|
return pm, fmt.Errorf("host_ip must be a valid IPv4 address")
|
||||||
|
}
|
||||||
|
if !containerHasPublicIPv4(c, pm.HostIP) {
|
||||||
|
return pm, fmt.Errorf("host_ip %s is not assigned to this container", pm.HostIP)
|
||||||
|
}
|
||||||
|
}
|
||||||
if pm.Description == "" {
|
if pm.Description == "" {
|
||||||
pm.Description = fmt.Sprintf("Port-%d", pm.ContainerPort)
|
pm.Description = fmt.Sprintf("Port-%d", pm.ContainerPort)
|
||||||
}
|
}
|
||||||
@@ -179,8 +357,8 @@ func normalizePortMapping(c *config.Container, skipIndex int, pm config.PortMapp
|
|||||||
if i == skipIndex {
|
if i == skipIndex {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if existing.HostPort == pm.HostPort && existing.Protocol == pm.Protocol {
|
if portMappingsConflict(c, pm, c, existing) {
|
||||||
return pm, fmt.Errorf("host port %d/%s already mapped in this container", pm.HostPort, pm.Protocol)
|
return pm, fmt.Errorf("host port %d/%s already mapped on the same IPv4 in this container", pm.HostPort, pm.Protocol)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Check all other containers (LXC + KVM) for port conflicts
|
// Check all other containers (LXC + KVM) for port conflicts
|
||||||
@@ -189,8 +367,9 @@ func normalizePortMapping(c *config.Container, skipIndex int, pm config.PortMapp
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for _, existing := range oc.PortMappings {
|
for _, existing := range oc.PortMappings {
|
||||||
if existing.HostPort == pm.HostPort && existing.Protocol == pm.Protocol {
|
oc := oc
|
||||||
return pm, fmt.Errorf("host port %d/%s already used by container %s (ID: %d)", pm.HostPort, pm.Protocol, oc.Name, oc.ID)
|
if portMappingsConflict(c, pm, &oc, existing) {
|
||||||
|
return pm, fmt.Errorf("host port %d/%s already used on the same IPv4 by container %s (ID: %d)", pm.HostPort, pm.Protocol, oc.Name, oc.ID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -204,7 +383,9 @@ func allocateDefaultEqualPorts(c *config.Container, count int) []int {
|
|||||||
used := map[int]bool{}
|
used := map[int]bool{}
|
||||||
// Mark current container's ports
|
// Mark current container's ports
|
||||||
for _, pm := range c.PortMappings {
|
for _, pm := range c.PortMappings {
|
||||||
used[pm.HostPort] = true
|
for _, hostIP := range expandPortMappingHostIPs(c, pm) {
|
||||||
|
used[hostPortKey(hostIP, pm.HostPort)] = true
|
||||||
|
}
|
||||||
used[pm.ContainerPort] = true
|
used[pm.ContainerPort] = true
|
||||||
}
|
}
|
||||||
// Also mark all other containers' host ports (LXC + KVM)
|
// Also mark all other containers' host ports (LXC + KVM)
|
||||||
@@ -213,13 +394,17 @@ func allocateDefaultEqualPorts(c *config.Container, count int) []int {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for _, pm := range oc.PortMappings {
|
for _, pm := range oc.PortMappings {
|
||||||
used[pm.HostPort] = true
|
oc := oc
|
||||||
|
for _, hostIP := range expandPortMappingHostIPs(&oc, pm) {
|
||||||
|
used[hostPortKey(hostIP, pm.HostPort)] = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ports := make([]int, 0, count)
|
ports := make([]int, 0, count)
|
||||||
next := 20000
|
next := 20000
|
||||||
for len(ports) < count {
|
for len(ports) < count {
|
||||||
if !used[next] {
|
hostIP := c.PrimaryPublicIPv4()
|
||||||
|
if !used[hostPortKey(hostIP, next)] && !used[next] {
|
||||||
ports = append(ports, next)
|
ports = append(ports, next)
|
||||||
}
|
}
|
||||||
next++
|
next++
|
||||||
@@ -229,3 +414,118 @@ func allocateDefaultEqualPorts(c *config.Container, count int) []int {
|
|||||||
}
|
}
|
||||||
return ports
|
return ports
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func HostPortAvailable(c *config.Container, hostIP string, hostPort int, protocol string) bool {
|
||||||
|
if c == nil || hostPort <= 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
pm := config.PortMapping{HostIP: strings.TrimSpace(hostIP), HostPort: hostPort, Protocol: protocol}
|
||||||
|
for _, existing := range c.PortMappings {
|
||||||
|
if portMappingsConflict(c, pm, c, existing) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, oc := range config.AppConfig.Containers {
|
||||||
|
if oc.ID == c.ID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
oc := oc
|
||||||
|
for _, existing := range oc.PortMappings {
|
||||||
|
if portMappingsConflict(c, pm, &oc, existing) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func expandPortMappingHostIPs(c *config.Container, pm config.PortMapping) []string {
|
||||||
|
if strings.TrimSpace(pm.HostIP) != "" {
|
||||||
|
return []string{strings.TrimSpace(pm.HostIP)}
|
||||||
|
}
|
||||||
|
if c != nil && len(c.PublicIPv4s) > 0 {
|
||||||
|
values := make([]string, 0, len(c.PublicIPv4s))
|
||||||
|
for _, item := range c.PublicIPv4s {
|
||||||
|
if strings.TrimSpace(item.Address) != "" {
|
||||||
|
values = append(values, strings.TrimSpace(item.Address))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(values) > 0 {
|
||||||
|
return values
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return []string{""}
|
||||||
|
}
|
||||||
|
|
||||||
|
func containerHasPublicIPv4(c *config.Container, hostIP string) bool {
|
||||||
|
if c == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, item := range c.PublicIPv4s {
|
||||||
|
if item.Address == hostIP {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func portMappingsConflict(aContainer *config.Container, a config.PortMapping, bContainer *config.Container, b config.PortMapping) bool {
|
||||||
|
if a.HostPort != b.HostPort || !protocolsOverlap(a.Protocol, b.Protocol) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
aIPs := expandPortMappingHostIPs(aContainer, a)
|
||||||
|
bIPs := expandPortMappingHostIPs(bContainer, b)
|
||||||
|
for _, aIP := range aIPs {
|
||||||
|
for _, bIP := range bIPs {
|
||||||
|
if aIP == "" || bIP == "" || aIP == bIP {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func protocolsOverlap(a, b string) bool {
|
||||||
|
a = strings.ToLower(strings.TrimSpace(a))
|
||||||
|
b = strings.ToLower(strings.TrimSpace(b))
|
||||||
|
if a == "" {
|
||||||
|
a = "tcp"
|
||||||
|
}
|
||||||
|
if b == "" {
|
||||||
|
b = "tcp"
|
||||||
|
}
|
||||||
|
if a == b || a == "all" || b == "all" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return (a == "tcp+udp" && (b == "tcp" || b == "udp")) ||
|
||||||
|
(b == "tcp+udp" && (a == "tcp" || a == "udp"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func natRuleIPTag(ip string) string {
|
||||||
|
ip = strings.TrimSpace(ip)
|
||||||
|
if ip == "" {
|
||||||
|
return "any"
|
||||||
|
}
|
||||||
|
return strings.ReplaceAll(ip, ".", "_")
|
||||||
|
}
|
||||||
|
|
||||||
|
func displayHostIP(ip string) string {
|
||||||
|
if strings.TrimSpace(ip) == "" {
|
||||||
|
return "host"
|
||||||
|
}
|
||||||
|
return ip
|
||||||
|
}
|
||||||
|
|
||||||
|
func hostPortKey(hostIP string, port int) int {
|
||||||
|
if hostIP == "" {
|
||||||
|
return port
|
||||||
|
}
|
||||||
|
sum := 0
|
||||||
|
for _, r := range hostIP {
|
||||||
|
sum = sum*31 + int(r)
|
||||||
|
}
|
||||||
|
if sum < 0 {
|
||||||
|
sum = -sum
|
||||||
|
}
|
||||||
|
return port + (sum % 1000000 * 100000)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,221 @@
|
|||||||
|
package lxc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
SSHAuthAutoPassword = "auto_password"
|
||||||
|
SSHAuthPassword = "password"
|
||||||
|
SSHAuthKey = "key"
|
||||||
|
SSHAuthKeep = "keep"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SSHAccess struct {
|
||||||
|
Mode string
|
||||||
|
Password string
|
||||||
|
PublicKey string
|
||||||
|
}
|
||||||
|
|
||||||
|
func HasSSHAuthOptions(cfg ContainerConfig) bool {
|
||||||
|
return strings.TrimSpace(cfg.SSHAuthMode) != "" ||
|
||||||
|
strings.TrimSpace(cfg.SSHPassword) != "" ||
|
||||||
|
strings.TrimSpace(cfg.SSHPublicKey) != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func ResolveCreateSSHAccess(cfg ContainerConfig) (SSHAccess, error) {
|
||||||
|
mode, err := resolveSSHAuthMode(cfg.SSHAuthMode, cfg.SSHPassword, cfg.SSHPublicKey, SSHAuthAutoPassword)
|
||||||
|
if err != nil {
|
||||||
|
return SSHAccess{}, err
|
||||||
|
}
|
||||||
|
if mode == SSHAuthKeep {
|
||||||
|
mode = SSHAuthAutoPassword
|
||||||
|
}
|
||||||
|
|
||||||
|
switch mode {
|
||||||
|
case SSHAuthAutoPassword:
|
||||||
|
return SSHAccess{Mode: mode, Password: generateRandomString(16)}, nil
|
||||||
|
case SSHAuthPassword:
|
||||||
|
password := strings.TrimSpace(cfg.SSHPassword)
|
||||||
|
if password == "" {
|
||||||
|
return SSHAccess{}, fmt.Errorf("请填写自定义 SSH 密码")
|
||||||
|
}
|
||||||
|
if err := ValidateCustomSSHPassword(password); err != nil {
|
||||||
|
return SSHAccess{}, err
|
||||||
|
}
|
||||||
|
return SSHAccess{Mode: mode, Password: password}, nil
|
||||||
|
case SSHAuthKey:
|
||||||
|
publicKey, err := NormalizeSSHPublicKey(cfg.SSHPublicKey)
|
||||||
|
if err != nil {
|
||||||
|
return SSHAccess{}, err
|
||||||
|
}
|
||||||
|
if publicKey == "" {
|
||||||
|
return SSHAccess{}, fmt.Errorf("请填写 SSH 公钥")
|
||||||
|
}
|
||||||
|
password := strings.TrimSpace(cfg.SSHPassword)
|
||||||
|
if password == "" {
|
||||||
|
password = generateRandomString(16)
|
||||||
|
} else if err := ValidateCustomSSHPassword(password); err != nil {
|
||||||
|
return SSHAccess{}, err
|
||||||
|
}
|
||||||
|
return SSHAccess{Mode: mode, Password: password, PublicKey: publicKey}, nil
|
||||||
|
default:
|
||||||
|
return SSHAccess{}, fmt.Errorf("不支持的 SSH 登录方式: %s", mode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ResolveReinstallSSHAccess(currentPassword string, cfg ContainerConfig) (SSHAccess, error) {
|
||||||
|
mode, err := resolveSSHAuthMode(cfg.SSHAuthMode, cfg.SSHPassword, cfg.SSHPublicKey, SSHAuthKeep)
|
||||||
|
if err != nil {
|
||||||
|
return SSHAccess{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
switch mode {
|
||||||
|
case SSHAuthKeep:
|
||||||
|
password := strings.TrimSpace(currentPassword)
|
||||||
|
if password == "" {
|
||||||
|
password = generateRandomString(16)
|
||||||
|
}
|
||||||
|
if err := validateRootPassword(password); err != nil {
|
||||||
|
return SSHAccess{}, err
|
||||||
|
}
|
||||||
|
return SSHAccess{Mode: mode, Password: password}, nil
|
||||||
|
case SSHAuthAutoPassword:
|
||||||
|
return SSHAccess{Mode: mode, Password: generateRandomString(16)}, nil
|
||||||
|
case SSHAuthPassword:
|
||||||
|
password := strings.TrimSpace(cfg.SSHPassword)
|
||||||
|
if password == "" {
|
||||||
|
return SSHAccess{}, fmt.Errorf("请填写自定义 SSH 密码")
|
||||||
|
}
|
||||||
|
if err := ValidateCustomSSHPassword(password); err != nil {
|
||||||
|
return SSHAccess{}, err
|
||||||
|
}
|
||||||
|
return SSHAccess{Mode: mode, Password: password}, nil
|
||||||
|
case SSHAuthKey:
|
||||||
|
publicKey, err := NormalizeSSHPublicKey(cfg.SSHPublicKey)
|
||||||
|
if err != nil {
|
||||||
|
return SSHAccess{}, err
|
||||||
|
}
|
||||||
|
if publicKey == "" {
|
||||||
|
return SSHAccess{}, fmt.Errorf("请填写 SSH 公钥")
|
||||||
|
}
|
||||||
|
password := strings.TrimSpace(cfg.SSHPassword)
|
||||||
|
if password != "" {
|
||||||
|
if err := ValidateCustomSSHPassword(password); err != nil {
|
||||||
|
return SSHAccess{}, err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
password = strings.TrimSpace(currentPassword)
|
||||||
|
if password == "" {
|
||||||
|
password = generateRandomString(16)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := validateRootPassword(password); err != nil {
|
||||||
|
return SSHAccess{}, err
|
||||||
|
}
|
||||||
|
return SSHAccess{Mode: mode, Password: password, PublicKey: publicKey}, nil
|
||||||
|
default:
|
||||||
|
return SSHAccess{}, fmt.Errorf("不支持的 SSH 登录方式: %s", mode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidateCustomSSHPassword(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 validateRootPassword(password)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NormalizeSSHPublicKey(publicKey string) (string, error) {
|
||||||
|
key := strings.TrimSpace(publicKey)
|
||||||
|
if key == "" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
if len(key) > 8192 {
|
||||||
|
return "", fmt.Errorf("SSH 公钥长度不能超过 8192 字符")
|
||||||
|
}
|
||||||
|
if strings.ContainsAny(key, "\r\n") || strings.ContainsRune(key, '\x00') {
|
||||||
|
return "", fmt.Errorf("SSH 公钥只能填写一行")
|
||||||
|
}
|
||||||
|
|
||||||
|
fields := strings.Fields(key)
|
||||||
|
if len(fields) < 2 {
|
||||||
|
return "", fmt.Errorf("SSH 公钥格式不正确")
|
||||||
|
}
|
||||||
|
if !isSupportedSSHKeyType(fields[0]) {
|
||||||
|
return "", fmt.Errorf("不支持的 SSH 公钥类型: %s", fields[0])
|
||||||
|
}
|
||||||
|
parsed, _, _, rest, err := ssh.ParseAuthorizedKey([]byte(key))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("SSH 公钥格式不正确")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(string(rest)) != "" {
|
||||||
|
return "", fmt.Errorf("一次只能填写一个 SSH 公钥")
|
||||||
|
}
|
||||||
|
if !isSupportedSSHKeyType(parsed.Type()) {
|
||||||
|
return "", fmt.Errorf("不支持的 SSH 公钥类型: %s", parsed.Type())
|
||||||
|
}
|
||||||
|
return key, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveSSHAuthMode(rawMode, password, publicKey, defaultMode string) (string, error) {
|
||||||
|
mode := strings.ToLower(strings.TrimSpace(rawMode))
|
||||||
|
mode = strings.ReplaceAll(mode, "-", "_")
|
||||||
|
if mode == "" {
|
||||||
|
if strings.TrimSpace(publicKey) != "" {
|
||||||
|
return SSHAuthKey, nil
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(password) != "" {
|
||||||
|
return SSHAuthPassword, nil
|
||||||
|
}
|
||||||
|
return defaultMode, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch mode {
|
||||||
|
case "auto", "auto_password", "generated", "generate":
|
||||||
|
return SSHAuthAutoPassword, nil
|
||||||
|
case "password", "custom_password":
|
||||||
|
return SSHAuthPassword, nil
|
||||||
|
case "key", "ssh_key", "public_key":
|
||||||
|
return SSHAuthKey, nil
|
||||||
|
case "keep", "retain", "keep_password":
|
||||||
|
return SSHAuthKeep, nil
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("不支持的 SSH 登录方式: %s", rawMode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isSupportedSSHKeyType(keyType string) bool {
|
||||||
|
switch keyType {
|
||||||
|
case "ssh-ed25519",
|
||||||
|
"ssh-rsa",
|
||||||
|
"ecdsa-sha2-nistp256",
|
||||||
|
"ecdsa-sha2-nistp384",
|
||||||
|
"ecdsa-sha2-nistp521",
|
||||||
|
"sk-ssh-ed25519@openssh.com",
|
||||||
|
"sk-ecdsa-sha2-nistp256@openssh.com":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -46,17 +46,17 @@ func GetTemplates() []Template {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
ID: "archlinux-current", Name: "Arch Linux",
|
ID: "archlinux-current", Name: "Arch Linux",
|
||||||
Distro: "archlinux", Release: "current", Arch: "amd64", Variant: "cloud",
|
Distro: "archlinux", Release: "current", Arch: "amd64",
|
||||||
Description: "Arch Linux (Rolling)",
|
Description: "Arch Linux (Rolling)",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ID: "fedora-44", Name: "Fedora 44",
|
ID: "fedora-44", Name: "Fedora 44",
|
||||||
Distro: "fedora", Release: "44", Arch: "amd64", Variant: "cloud",
|
Distro: "fedora", Release: "44", Arch: "amd64",
|
||||||
Description: "Fedora 44",
|
Description: "Fedora 44",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ID: "rockylinux-10", Name: "Rocky Linux 10",
|
ID: "rockylinux-10", Name: "Rocky Linux 10",
|
||||||
Distro: "rockylinux", Release: "10", Arch: "amd64", Variant: "cloud",
|
Distro: "rockylinux", Release: "10", Arch: "amd64",
|
||||||
Description: "Rocky Linux 10",
|
Description: "Rocky Linux 10",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/tls"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"clicd/internal/api"
|
"clicd/internal/api"
|
||||||
@@ -18,16 +17,16 @@ var webFS http.FileSystem
|
|||||||
// corsMiddleware adds CORS headers
|
// corsMiddleware adds CORS headers
|
||||||
func corsMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
func corsMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
if origin := r.Header.Get("Origin"); origin != "" && isAllowedOrigin(origin, r.Host) {
|
if origin := r.Header.Get("Origin"); origin != "" && config.IsOriginAllowed(origin, r.Host) {
|
||||||
w.Header().Set("Access-Control-Allow-Origin", origin)
|
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||||
w.Header().Set("Vary", "Origin")
|
w.Header().Set("Vary", "Origin")
|
||||||
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
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")
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-API-Key")
|
||||||
|
|
||||||
if r.Method == http.MethodOptions {
|
if r.Method == http.MethodOptions {
|
||||||
if origin := r.Header.Get("Origin"); origin != "" && !isAllowedOrigin(origin, r.Host) {
|
if origin := r.Header.Get("Origin"); origin != "" && !config.IsOriginAllowed(origin, r.Host) {
|
||||||
w.WriteHeader(http.StatusForbidden)
|
w.WriteHeader(http.StatusForbidden)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -39,43 +38,19 @@ func corsMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func isAllowedOrigin(origin string, requestHost string) bool {
|
|
||||||
u, err := url.Parse(origin)
|
|
||||||
if err != nil || u.Host == "" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
originHost := normalizeHost(u.Host)
|
|
||||||
host := normalizeHost(requestHost)
|
|
||||||
if originHost == host {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return isLoopbackHost(originHost) && isLoopbackHost(host)
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizeHost(host string) string {
|
|
||||||
if h, _, err := net.SplitHostPort(host); err == nil {
|
|
||||||
return strings.ToLower(h)
|
|
||||||
}
|
|
||||||
return strings.ToLower(host)
|
|
||||||
}
|
|
||||||
|
|
||||||
func isLoopbackHost(host string) bool {
|
|
||||||
if host == "localhost" {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
ip := net.ParseIP(host)
|
|
||||||
return ip != nil && ip.IsLoopback()
|
|
||||||
}
|
|
||||||
|
|
||||||
// setupRoutes configures API and static routes
|
// setupRoutes configures API and static routes
|
||||||
func setupRoutes(mux *http.ServeMux) {
|
func setupRoutes(mux *http.ServeMux) {
|
||||||
// API routes
|
// API routes
|
||||||
mux.HandleFunc("/api/login", corsMiddleware(api.HandleLogin))
|
mux.HandleFunc("/api/login", corsMiddleware(api.HandleLogin))
|
||||||
|
mux.HandleFunc("/api/language", corsMiddleware(api.HandleLanguage))
|
||||||
mux.HandleFunc("/api/check-auth", corsMiddleware(api.AuthMiddleware(api.HandleCheckAuth)))
|
mux.HandleFunc("/api/check-auth", corsMiddleware(api.AuthMiddleware(api.HandleCheckAuth)))
|
||||||
mux.HandleFunc("/api/change-password", corsMiddleware(api.AdminMiddleware(api.HandleAdminPasswordChange)))
|
mux.HandleFunc("/api/change-password", corsMiddleware(api.AdminMiddleware(api.HandleAdminPasswordChange)))
|
||||||
mux.HandleFunc("/api/change-username", corsMiddleware(api.AdminMiddleware(api.HandleAdminUsernameChange)))
|
mux.HandleFunc("/api/change-username", corsMiddleware(api.AdminMiddleware(api.HandleAdminUsernameChange)))
|
||||||
mux.HandleFunc("/api/login-logs", corsMiddleware(api.AdminMiddleware(api.HandleLoginLogs)))
|
mux.HandleFunc("/api/login-logs", corsMiddleware(api.AdminMiddleware(api.HandleLoginLogs)))
|
||||||
|
mux.HandleFunc("/api/ssl", corsMiddleware(api.AdminMiddleware(api.HandleSSLSettings)))
|
||||||
|
mux.HandleFunc("/api/webssh-origins", corsMiddleware(api.AdminMiddleware(api.HandleWebSSHOriginSettings)))
|
||||||
mux.HandleFunc("/api/containers", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleContainers))))
|
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/containers/", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleSingleContainer))))
|
||||||
mux.HandleFunc("/api/templates", corsMiddleware(api.AuthMiddleware(api.HandleTemplates)))
|
mux.HandleFunc("/api/templates", corsMiddleware(api.AuthMiddleware(api.HandleTemplates)))
|
||||||
mux.HandleFunc("/api/images", corsMiddleware(api.AdminMiddleware(api.HandleImages)))
|
mux.HandleFunc("/api/images", corsMiddleware(api.AdminMiddleware(api.HandleImages)))
|
||||||
@@ -86,7 +61,9 @@ func setupRoutes(mux *http.ServeMux) {
|
|||||||
mux.HandleFunc("/api/images/enabled", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleEnabledImages))))
|
mux.HandleFunc("/api/images/enabled", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleEnabledImages))))
|
||||||
mux.HandleFunc("/api/dashboard", corsMiddleware(api.AdminMiddleware(api.HandleDashboard)))
|
mux.HandleFunc("/api/dashboard", corsMiddleware(api.AdminMiddleware(api.HandleDashboard)))
|
||||||
mux.HandleFunc("/api/host-info", corsMiddleware(api.AdminMiddleware(api.HandleHostInfo)))
|
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/snapshots", corsMiddleware(api.AdminMiddleware(api.HandleSnapshots)))
|
||||||
|
mux.HandleFunc("/api/routing/ipv4-scan", corsMiddleware(api.AdminMiddleware(api.HandleRoutingIPv4Scan)))
|
||||||
mux.HandleFunc("/api/routing", corsMiddleware(api.AdminMiddleware(api.HandleRouting)))
|
mux.HandleFunc("/api/routing", corsMiddleware(api.AdminMiddleware(api.HandleRouting)))
|
||||||
mux.HandleFunc("/api/ipv6/status", corsMiddleware(api.AdminMiddleware(api.HandleIPv6Status)))
|
mux.HandleFunc("/api/ipv6/status", corsMiddleware(api.AdminMiddleware(api.HandleIPv6Status)))
|
||||||
mux.HandleFunc("/api/tasks", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleTasks))))
|
mux.HandleFunc("/api/tasks", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleTasks))))
|
||||||
@@ -113,6 +90,53 @@ func setupRoutes(mux *http.ServeMux) {
|
|||||||
mux.HandleFunc("/api/api-keys", corsMiddleware(api.AdminMiddleware(api.HandleApiKeys)))
|
mux.HandleFunc("/api/api-keys", corsMiddleware(api.AdminMiddleware(api.HandleApiKeys)))
|
||||||
mux.HandleFunc("/api/api-keys/", corsMiddleware(api.AdminMiddleware(api.HandleApiKeyDelete)))
|
mux.HandleFunc("/api/api-keys/", corsMiddleware(api.AdminMiddleware(api.HandleApiKeyDelete)))
|
||||||
|
|
||||||
|
// Versioned external API routes
|
||||||
|
mux.HandleFunc("/api/v1/dashboard", corsMiddleware(api.AuthMiddleware(api.HandleDashboard)))
|
||||||
|
mux.HandleFunc("/api/v1/language", corsMiddleware(api.HandleLanguage))
|
||||||
|
mux.HandleFunc("/api/v1/containers", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleContainers))))
|
||||||
|
mux.HandleFunc("/api/v1/containers/list", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleContainerListAlias))))
|
||||||
|
mux.HandleFunc("/api/v1/containers/", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleSingleContainer))))
|
||||||
|
mux.HandleFunc("/api/v1/templates", corsMiddleware(api.AuthMiddleware(api.HandleTemplates)))
|
||||||
|
mux.HandleFunc("/api/v1/images", corsMiddleware(api.AuthMiddleware(api.HandleImages)))
|
||||||
|
mux.HandleFunc("/api/v1/images/download", corsMiddleware(api.AuthMiddleware(api.HandleImageDownload)))
|
||||||
|
mux.HandleFunc("/api/v1/images/cancel", corsMiddleware(api.AuthMiddleware(api.HandleImageCancel)))
|
||||||
|
mux.HandleFunc("/api/v1/images/delete", corsMiddleware(api.AuthMiddleware(api.HandleImageDelete)))
|
||||||
|
mux.HandleFunc("/api/v1/images/toggle", corsMiddleware(api.AuthMiddleware(api.HandleImageToggle)))
|
||||||
|
mux.HandleFunc("/api/v1/images/enabled", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleEnabledImages))))
|
||||||
|
mux.HandleFunc("/api/v1/host-info", corsMiddleware(api.AuthMiddleware(api.HandleHostInfo)))
|
||||||
|
mux.HandleFunc("/api/v1/host-report", corsMiddleware(api.AuthMiddleware(api.HandleHostReport)))
|
||||||
|
mux.HandleFunc("/api/v1/snapshots", corsMiddleware(api.AuthMiddleware(api.ScopeMiddleware("snapshot:read", api.HandleSnapshots))))
|
||||||
|
mux.HandleFunc("/api/v1/routing/ipv4-scan", corsMiddleware(api.AuthMiddleware(api.HandleRoutingIPv4Scan)))
|
||||||
|
mux.HandleFunc("/api/v1/routing", corsMiddleware(api.AuthMiddleware(api.HandleRouting)))
|
||||||
|
mux.HandleFunc("/api/v1/ipv6/status", corsMiddleware(api.AuthMiddleware(api.HandleIPv6Status)))
|
||||||
|
mux.HandleFunc("/api/v1/tasks", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleTasks))))
|
||||||
|
mux.HandleFunc("/api/v1/tasks/", corsMiddleware(api.AuthMiddleware(api.HandleTaskDelete)))
|
||||||
|
mux.HandleFunc("/api/v1/batch-create", corsMiddleware(api.AuthMiddleware(api.HandleBatchCreate)))
|
||||||
|
mux.HandleFunc("/api/v1/batch-action", corsMiddleware(api.AuthMiddleware(api.HandleBatchAction)))
|
||||||
|
mux.HandleFunc("/api/v1/sub-user/create", corsMiddleware(api.AuthMiddleware(api.HandleSubUserCreate)))
|
||||||
|
mux.HandleFunc("/api/v1/sub-users", corsMiddleware(api.AuthMiddleware(api.HandleSubUserList)))
|
||||||
|
mux.HandleFunc("/api/v1/sub-users/", corsMiddleware(api.AuthMiddleware(api.HandleSubUserAction)))
|
||||||
|
mux.HandleFunc("/api/v1/audit-logs", corsMiddleware(api.AuthMiddleware(api.HandleAuditLogs)))
|
||||||
|
mux.HandleFunc("/api/v1/login-logs", corsMiddleware(api.AuthMiddleware(api.HandleLoginLogs)))
|
||||||
|
mux.HandleFunc("/api/v1/ssl", corsMiddleware(api.AdminMiddleware(api.HandleSSLSettings)))
|
||||||
|
mux.HandleFunc("/api/v1/webssh-origins", corsMiddleware(api.AdminMiddleware(api.HandleWebSSHOriginSettings)))
|
||||||
|
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)
|
// Version (public)
|
||||||
mux.HandleFunc("/api/version", corsMiddleware(api.HandleVersion))
|
mux.HandleFunc("/api/version", corsMiddleware(api.HandleVersion))
|
||||||
|
|
||||||
@@ -163,5 +187,63 @@ func Run() error {
|
|||||||
Handler: mux,
|
Handler: mux,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if sslEnabled() {
|
||||||
|
certPath, keyPath, err := config.ResolveSSLConfigPaths(config.AppConfig.SSL)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
server.TLSConfig = &tls.Config{
|
||||||
|
MinVersion: tls.VersionTLS12,
|
||||||
|
GetCertificate: func(*tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||||
|
safeCertPath, err := config.ResolveSSLPath(certPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
safeKeyPath, err := config.ResolveSSLPath(keyPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cert, err := tls.LoadX509KeyPair(safeCertPath, safeKeyPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &cert, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
log.Printf("CLICD Web Server SSL enabled on https://0.0.0.0:%d", config.AppConfig.Port)
|
||||||
|
return server.ListenAndServeTLS("", "")
|
||||||
|
}
|
||||||
|
|
||||||
return server.ListenAndServe()
|
return server.ListenAndServe()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func sslEnabled() bool {
|
||||||
|
ssl := config.AppConfig.SSL
|
||||||
|
if !ssl.Enabled {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
certPath, keyPath, err := config.ResolveSSLConfigPaths(ssl)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("SSL paths are invalid, falling back to HTTP: %v", err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
safeCertPath, err := config.ResolveSSLPath(certPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("SSL certificate path is not allowed, falling back to HTTP: %v", err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
safeKeyPath, err := config.ResolveSSLPath(keyPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("SSL private key path is not allowed, falling back to HTTP: %v", err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if _, err := config.ReadableFileStat(safeCertPath); err != nil {
|
||||||
|
log.Printf("SSL certificate is not readable, falling back to HTTP: %v", err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if _, err := config.ReadableFileStat(safeKeyPath); err != nil {
|
||||||
|
log.Printf("SSL private key is not readable, falling back to HTTP: %v", err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package version
|
package version
|
||||||
|
|
||||||
var (
|
var (
|
||||||
Version = "1.1.2"
|
Version = "1.1.13"
|
||||||
Repo = "MengMengCode/CLICD"
|
Repo = "MengMengCode/CLICD"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+30
-2
@@ -50,10 +50,12 @@ func main() {
|
|||||||
|
|
||||||
// Start security scanner
|
// Start security scanner
|
||||||
api.InitScanner()
|
api.InitScanner()
|
||||||
|
api.StartSSLRenewalMonitor()
|
||||||
|
|
||||||
// Ensure iptables FORWARD rules allow managed bridge traffic.
|
// Ensure iptables FORWARD rules allow managed bridge traffic.
|
||||||
lxc.EnsureForwardRules("lxcbr0")
|
lxc.EnsureForwardRules("lxcbr0")
|
||||||
lxc.EnsureForwardRules("virbr0")
|
lxc.EnsureForwardRules("virbr0")
|
||||||
|
lxc.EnsureAllAssignedPublicIPv4s()
|
||||||
|
|
||||||
// Start expiry scanners (stops expired/over-traffic workloads every 30s)
|
// Start expiry scanners (stops expired/over-traffic workloads every 30s)
|
||||||
manager := lxc.NewManager()
|
manager := lxc.NewManager()
|
||||||
@@ -73,6 +75,7 @@ func main() {
|
|||||||
|
|
||||||
// Clean up stale container configs (LXC dir was deleted but config remains)
|
// Clean up stale container configs (LXC dir was deleted but config remains)
|
||||||
config.CleanStaleContainers()
|
config.CleanStaleContainers()
|
||||||
|
lxc.EnsureAllRunningPortMappings()
|
||||||
|
|
||||||
// Pre-warm SSH for containers already running after host boot or service restart.
|
// Pre-warm SSH for containers already running after host boot or service restart.
|
||||||
manager.StartSSHWarmupScanner()
|
manager.StartSSHWarmupScanner()
|
||||||
@@ -106,8 +109,33 @@ func isWebPanelSystemdRunning() bool {
|
|||||||
func startWebPanelSystemd() {
|
func startWebPanelSystemd() {
|
||||||
cmd := exec.Command("systemctl", "start", "clicd")
|
cmd := exec.Command("systemctl", "start", "clicd")
|
||||||
if err := cmd.Run(); err != nil {
|
if err := cmd.Run(); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "警告: 自动启动 Web 面板失败: %v\n", err)
|
fmt.Fprintf(os.Stderr, "%s: %v\n", mainT("警告: 自动启动 Web 面板失败"), err)
|
||||||
} else {
|
} else {
|
||||||
fmt.Println("Web 面板已自动启动")
|
fmt.Println(mainT("Web 面板已自动启动"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func mainT(text string) string {
|
||||||
|
if !mainEnglish() {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
switch text {
|
||||||
|
case "警告: 自动启动 Web 面板失败":
|
||||||
|
return "Warning: failed to auto-start web panel"
|
||||||
|
case "Web 面板已自动启动":
|
||||||
|
return "Web panel auto-started"
|
||||||
|
default:
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mainEnglish() bool {
|
||||||
|
lang := strings.ToLower(strings.TrimSpace(os.Getenv("CLICD_LANG")))
|
||||||
|
if lang == "en" || strings.HasPrefix(lang, "en_") || strings.HasPrefix(lang, "en-") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if lang == "zh" || strings.HasPrefix(lang, "zh_") || strings.HasPrefix(lang, "zh-") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return config.AppConfig != nil && config.NormalizeLanguage(config.AppConfig.Language) == "en"
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
.vitepress/cache/
|
||||||
|
.vitepress/dist/
|
||||||
|
.vitepress/.temp/
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
import { defineConfig } from 'vitepress'
|
||||||
|
|
||||||
|
const zhNav = [
|
||||||
|
{ text: '指南', link: '/guide/introduction' },
|
||||||
|
{ text: '功能', link: '/features/dashboard' },
|
||||||
|
{ text: '运维', link: '/operations/deployment' },
|
||||||
|
{ text: '开发', link: '/developer/architecture' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const enNav = [
|
||||||
|
{ text: 'Guide', link: '/en/guide/introduction' },
|
||||||
|
{ text: 'Features', link: '/en/features/dashboard' },
|
||||||
|
{ text: 'Operations', link: '/en/operations/deployment' },
|
||||||
|
{ text: 'Developer', link: '/en/developer/architecture' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const zhSidebar = [
|
||||||
|
{
|
||||||
|
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' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const enSidebar = [
|
||||||
|
{
|
||||||
|
text: 'Get Started',
|
||||||
|
items: [
|
||||||
|
{ text: 'Introduction', link: '/en/guide/introduction' },
|
||||||
|
{ text: 'Installation', link: '/en/guide/installation' },
|
||||||
|
{ text: 'Upgrade', link: '/en/guide/upgrade' },
|
||||||
|
{ text: 'Quick Start', link: '/en/guide/quick-start' },
|
||||||
|
{ text: 'Configuration', link: '/en/guide/configuration' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'Features',
|
||||||
|
items: [
|
||||||
|
{ text: 'Dashboard', link: '/en/features/dashboard' },
|
||||||
|
{ text: 'Containers', link: '/en/features/containers' },
|
||||||
|
{ text: 'Images', link: '/en/features/images' },
|
||||||
|
{ text: 'Networking & Routing', link: '/en/features/networking' },
|
||||||
|
{ text: 'Snapshots', link: '/en/features/snapshots' },
|
||||||
|
{ text: 'Security Alerts', link: '/en/features/security' },
|
||||||
|
{ text: 'Sub-users', link: '/en/features/sub-users' },
|
||||||
|
{ text: 'API Integration', link: '/en/features/api' },
|
||||||
|
{ text: 'Host Report', link: '/en/features/host-report' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'Operations',
|
||||||
|
items: [
|
||||||
|
{ text: 'Deployment', link: '/en/operations/deployment' },
|
||||||
|
{ text: 'Troubleshooting', link: '/en/operations/troubleshooting' },
|
||||||
|
{ text: 'FAQ', link: '/en/operations/faq' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'Developer',
|
||||||
|
items: [
|
||||||
|
{ text: 'Architecture', link: '/en/developer/architecture' },
|
||||||
|
{ text: 'Local Build', link: '/en/developer/build' },
|
||||||
|
{ text: 'Release Process', link: '/en/developer/release' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
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' }],
|
||||||
|
],
|
||||||
|
locales: {
|
||||||
|
root: {
|
||||||
|
label: '简体中文',
|
||||||
|
lang: 'zh-CN',
|
||||||
|
description: '面向 LXC/KVM 的轻量虚拟化管理面板文档',
|
||||||
|
themeConfig: {
|
||||||
|
nav: zhNav,
|
||||||
|
sidebar: zhSidebar,
|
||||||
|
outline: {
|
||||||
|
label: '页面导航',
|
||||||
|
},
|
||||||
|
darkModeSwitchLabel: '外观',
|
||||||
|
sidebarMenuLabel: '菜单',
|
||||||
|
returnToTopLabel: '返回顶部',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
label: 'English',
|
||||||
|
lang: 'en-US',
|
||||||
|
link: '/en/',
|
||||||
|
description: 'Documentation for the lightweight LXC/KVM virtualization management panel.',
|
||||||
|
themeConfig: {
|
||||||
|
nav: enNav,
|
||||||
|
sidebar: enSidebar,
|
||||||
|
outline: {
|
||||||
|
label: 'On This Page',
|
||||||
|
},
|
||||||
|
darkModeSwitchLabel: 'Appearance',
|
||||||
|
sidebarMenuLabel: 'Menu',
|
||||||
|
returnToTopLabel: 'Return to Top',
|
||||||
|
footer: {
|
||||||
|
message: 'CLICD documentation for deployment, usage, operations, and integration.',
|
||||||
|
copyright: 'Copyright © CLICD contributors',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
themeConfig: {
|
||||||
|
logo: '/favicon.svg',
|
||||||
|
search: {
|
||||||
|
provider: 'local',
|
||||||
|
},
|
||||||
|
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,42 @@
|
|||||||
|
# Architecture
|
||||||
|
|
||||||
|
CLICD consists of a Go backend, a React frontend, and host virtualization capabilities.
|
||||||
|
|
||||||
|
## Backend
|
||||||
|
|
||||||
|
The backend entry point is `backend/main.go`, and HTTP routes are centralized in `backend/internal/server/server.go`. Main modules:
|
||||||
|
|
||||||
|
- `internal/api`: HTTP APIs for the web panel and `/api/v1`.
|
||||||
|
- `internal/config`: configuration and SQLite storage.
|
||||||
|
- `internal/lxc`: LXC container management.
|
||||||
|
- `internal/kvm`: KVM/libvirt virtual machine management.
|
||||||
|
- `internal/cli`: command-line management entry point.
|
||||||
|
- `internal/server`: embedded frontend assets and HTTP service.
|
||||||
|
- `internal/version`: version number.
|
||||||
|
|
||||||
|
## Frontend
|
||||||
|
|
||||||
|
The frontend entry point is `frontend/src/main.tsx`. Pages live in `frontend/src/pages`, and shared components live in `frontend/src/components`.
|
||||||
|
|
||||||
|
Main pages:
|
||||||
|
|
||||||
|
- Dashboard: `Dashboard.tsx`
|
||||||
|
- Container list: `Containers.tsx`
|
||||||
|
- Container details: `ContainerDetail.tsx`
|
||||||
|
- Image Management: `ImageManagement.tsx`
|
||||||
|
- Security Alerts: `Security.tsx`
|
||||||
|
- Snapshot Management: `Snapshots.tsx`
|
||||||
|
- Routing Management: `Routing.tsx`
|
||||||
|
- API Integration: `ApiIntegration.tsx`
|
||||||
|
- Host Report: `HostReport.tsx`
|
||||||
|
- Sub-user Management: `SubUserManagement.tsx`
|
||||||
|
|
||||||
|
## Frontend Embedding
|
||||||
|
|
||||||
|
For production builds, frontend artifacts are placed in `backend/internal/server/web`. The backend serves them through Go embed and returns the SPA entry for non-API routes.
|
||||||
|
|
||||||
|
## API Layers
|
||||||
|
|
||||||
|
- `/api/*`: web panel and compatibility APIs.
|
||||||
|
- `/api/v1/*`: versioned APIs recommended for external automation.
|
||||||
|
- WebSSH and WebVNC use short-lived tickets before opening WebSocket connections.
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# Local Build
|
||||||
|
|
||||||
|
## Frontend Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Build output is written to `frontend/dist`.
|
||||||
|
|
||||||
|
## Backend Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
go test ./...
|
||||||
|
go build -o ../build/clicd .
|
||||||
|
```
|
||||||
|
|
||||||
|
To package the embedded web panel, sync the frontend build output into the backend embed directory first.
|
||||||
|
|
||||||
|
## One-command Build
|
||||||
|
|
||||||
|
The project root provides a build script:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash build.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
The script chains frontend build, static asset sync, and Go binary build.
|
||||||
|
|
||||||
|
## Docs Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd docs
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
`npm run dev` starts a local preview, and `npm run build` generates static documentation.
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# Release Process
|
||||||
|
|
||||||
|
CLICD installation and upgrade rely on GitHub Release artifacts. Use semantic version tags such as `v1.1.6`.
|
||||||
|
|
||||||
|
## Version Number
|
||||||
|
|
||||||
|
Check the version in:
|
||||||
|
|
||||||
|
- `backend/internal/version/version.go`
|
||||||
|
- `frontend/package.json`
|
||||||
|
- Release tag.
|
||||||
|
|
||||||
|
## Release Artifacts
|
||||||
|
|
||||||
|
The installer first tries to download the Linux AMD64 archive:
|
||||||
|
|
||||||
|
```text
|
||||||
|
clicd-linux-amd64.tar.gz
|
||||||
|
```
|
||||||
|
|
||||||
|
In some cases, it may also try the standalone binary:
|
||||||
|
|
||||||
|
```text
|
||||||
|
clicd-linux-amd64
|
||||||
|
```
|
||||||
|
|
||||||
|
## Installer Behavior
|
||||||
|
|
||||||
|
- `CLICD_VERSION=latest`: use GitHub `releases/latest`.
|
||||||
|
- `CLICD_VERSION=vX.Y.Z`: download artifacts from the specified release tag.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
CLICD_VERSION=v1.1.6 sh install.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Post-release Verification
|
||||||
|
|
||||||
|
- The installer can download the new version.
|
||||||
|
- `systemctl status clicd` is healthy.
|
||||||
|
- `/api/version` returns the new version.
|
||||||
|
- The web panel can load frontend assets.
|
||||||
|
- Container list, task queue, and API Key pages open correctly.
|
||||||
@@ -0,0 +1,674 @@
|
|||||||
|
# API Integration
|
||||||
|
|
||||||
|
CLICD remains compatible with legacy `/api` endpoints, so existing integrations do not need to change. New integrations should use `/api/v1`; the list below is all v1, and the recommended container list endpoint is `GET /api/v1/containers`.
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
API keys can be created and managed from the API Integration page. Requests support either of these headers:
|
||||||
|
|
||||||
|
```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
|
||||||
|
```
|
||||||
|
|
||||||
|
## Response Shape
|
||||||
|
|
||||||
|
All APIs use the same response envelope:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "OK",
|
||||||
|
"data": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Integrations should read only the business fields they need. New capabilities are added as optional fields where possible, without requiring existing plugins to rename current fields.
|
||||||
|
|
||||||
|
## Creation and Reinstall
|
||||||
|
|
||||||
|
Container creation, batch creation, reinstall, and batch reinstall support mixed NAT, public IPv4, IPv6 networking, plus Linux SSH login configuration. Public IPv4/IPv6 pools can be viewed with `GET /api/v1/routing` and updated with `PUT /api/v1/routing`.
|
||||||
|
|
||||||
|
Create container example:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "demo-lxc-01",
|
||||||
|
"virtualization": "lxc",
|
||||||
|
"template_id": "debian-bookworm",
|
||||||
|
"vcpu": 1,
|
||||||
|
"ram_mb": 512,
|
||||||
|
"disk_gb": 10,
|
||||||
|
"assign_nat": true,
|
||||||
|
"port_mapping_count": 2,
|
||||||
|
"assign_ipv4": false,
|
||||||
|
"ipv4_count": 1,
|
||||||
|
"public_ipv4s": [],
|
||||||
|
"assign_ipv6": true,
|
||||||
|
"ipv6_count": 1,
|
||||||
|
"ipv6_addresses": [],
|
||||||
|
"ssh_auth_mode": "auto_password",
|
||||||
|
"ssh_password": "",
|
||||||
|
"ssh_public_key": "",
|
||||||
|
"expires_at": ""
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Field notes:
|
||||||
|
|
||||||
|
| Field | Description |
|
||||||
|
| --- | --- |
|
||||||
|
| `assign_nat` | Whether to allocate NAT port mappings. If omitted, default NAT behavior is preserved. |
|
||||||
|
| `assign_ipv4` | Whether to allocate public IPv4. |
|
||||||
|
| `ipv4_count` | Number of public IPv4 addresses to allocate automatically. |
|
||||||
|
| `public_ipv4s` | Explicit public IPv4 address list. |
|
||||||
|
| `assign_ipv6` | Whether to allocate IPv6. |
|
||||||
|
| `ipv6_count` | Number of IPv6 addresses to allocate automatically. |
|
||||||
|
| `ipv6_addresses` | Explicit IPv6 address list. |
|
||||||
|
| `ssh_auth_mode` | Linux creation supports `auto_password`, `password`, and `key`; reinstall also supports `keep`. |
|
||||||
|
| `ssh_password` | Custom password for `password` mode. It must be 8-64 characters, include letters and digits, and contain no whitespace. |
|
||||||
|
| `ssh_public_key` | One-line SSH public key for `key` mode. |
|
||||||
|
|
||||||
|
Reinstall example:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"template_id": "debian-bookworm",
|
||||||
|
"ssh_auth_mode": "keep",
|
||||||
|
"ssh_password": "",
|
||||||
|
"ssh_public_key": ""
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`keep` is only for reinstall and keeps the current SSH password. Windows KVM images ignore Linux SSH public key fields.
|
||||||
|
|
||||||
|
## Python Example
|
||||||
|
|
||||||
|
Fetch containers:
|
||||||
|
|
||||||
|
```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()
|
||||||
|
print(resp.json())
|
||||||
|
```
|
||||||
|
|
||||||
|
Create a port mapping:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import requests
|
||||||
|
|
||||||
|
BASE_URL = "https://panel.example.com"
|
||||||
|
API_KEY = "YOUR_API_KEY"
|
||||||
|
CONTAINER_ID = "example-vm"
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"protocol": "tcp",
|
||||||
|
"host_port": 18080,
|
||||||
|
"container_port": 80,
|
||||||
|
"description": "web",
|
||||||
|
}
|
||||||
|
|
||||||
|
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())
|
||||||
|
```
|
||||||
|
|
||||||
|
## Endpoint List
|
||||||
|
|
||||||
|
### Overview
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| GET | `/api/v1/dashboard` | Dashboard statistics |
|
||||||
|
| GET | `/api/v1/host-info` | Host resources |
|
||||||
|
| GET | `/api/v1/routing` | NAT/IPv4/IPv6 routing |
|
||||||
|
| PUT | `/api/v1/routing` | Update public IPv4/IPv6 pools |
|
||||||
|
| POST | `/api/v1/routing/ipv4-scan` | Scan a public IPv4 segment |
|
||||||
|
| GET | `/api/v1/ipv6/status` | IPv6 status |
|
||||||
|
| GET | `/api/v1/tasks` | Task queue |
|
||||||
|
| DELETE | `/api/v1/tasks/{task_id}` | Delete a task |
|
||||||
|
|
||||||
|
### Containers
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| GET | `/api/v1/containers` | Container list |
|
||||||
|
| POST | `/api/v1/containers/list` | Compatible POST form for container list |
|
||||||
|
| POST | `/api/v1/containers` | Create container |
|
||||||
|
| GET | `/api/v1/containers/{id|uuid|name}` | Container details |
|
||||||
|
| POST | `/api/v1/containers/{id}/start` | Start |
|
||||||
|
| POST | `/api/v1/containers/{id}/stop` | Stop |
|
||||||
|
| POST | `/api/v1/containers/{id}/restart` | Restart |
|
||||||
|
| POST | `/api/v1/containers/{id}/reinstall` | Reinstall |
|
||||||
|
| DELETE | `/api/v1/containers/{id}/delete` | Delete |
|
||||||
|
| GET | `/api/v1/containers/{id}/usage` | Resource usage |
|
||||||
|
| GET | `/api/v1/containers/{id}/traffic` | Traffic statistics |
|
||||||
|
| POST | `/api/v1/containers/{id}/traffic-reset` | Reset traffic |
|
||||||
|
| PUT | `/api/v1/containers/{id}/traffic-limit` | Update traffic limits |
|
||||||
|
| PUT | `/api/v1/containers/{id}/resource-limit` | Update resource limits |
|
||||||
|
| PUT | `/api/v1/containers/{id}/expiry` | Update expiration time |
|
||||||
|
| POST | `/api/v1/containers/{id}/reset-password` | Reset SSH password |
|
||||||
|
| POST | `/api/v1/containers/{id}/ipv6` | Assign IPv6 |
|
||||||
|
|
||||||
|
### Ports and Snapshots
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| GET | `/api/v1/containers/{id}/random-port` | Random available port |
|
||||||
|
| POST | `/api/v1/containers/{id}/port-mappings` | Add port mapping |
|
||||||
|
| PUT | `/api/v1/containers/{id}/port-mappings/{index}` | Update port mapping |
|
||||||
|
| DELETE | `/api/v1/containers/{id}/port-mappings/{index}` | Delete port mapping |
|
||||||
|
| GET | `/api/v1/snapshots` | Snapshot overview |
|
||||||
|
| GET | `/api/v1/containers/{id}/snapshots` | Container snapshots |
|
||||||
|
| POST | `/api/v1/containers/{id}/snapshots` | Create snapshot |
|
||||||
|
| DELETE | `/api/v1/containers/{id}/snapshots/{snapshot_id}` | Delete snapshot |
|
||||||
|
| POST | `/api/v1/containers/{id}/snapshots/{snapshot_id}/restore` | Restore snapshot |
|
||||||
|
| POST | `/api/v1/containers/{id}/snapshots/schedule` | Schedule snapshots |
|
||||||
|
| PUT | `/api/v1/containers/{id}/snapshots/quota` | Snapshot quota |
|
||||||
|
|
||||||
|
### Platform Management
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| GET | `/api/v1/templates` | Template list |
|
||||||
|
| GET | `/api/v1/images` | Image management list |
|
||||||
|
| POST | `/api/v1/images/download` | Download image |
|
||||||
|
| POST | `/api/v1/images/cancel` | Cancel image download |
|
||||||
|
| DELETE | `/api/v1/images/delete` | Delete image cache |
|
||||||
|
| PUT | `/api/v1/images/toggle` | Enable or disable image |
|
||||||
|
| GET | `/api/v1/security/alerts` | Security alerts |
|
||||||
|
| POST | `/api/v1/security/check` | Run security check |
|
||||||
|
| GET | `/api/v1/security/logs?container={name}` | Security connection logs |
|
||||||
|
| GET | `/api/v1/security/summary` | Security summary |
|
||||||
|
| GET | `/api/v1/security/settings` | Security settings |
|
||||||
|
| PUT | `/api/v1/security/settings` | Update security settings |
|
||||||
|
| GET | `/api/v1/swap` | Swap information |
|
||||||
|
| POST | `/api/v1/swap` | Adjust Swap |
|
||||||
|
| POST | `/api/v1/batch-create` | Batch create containers |
|
||||||
|
| POST | `/api/v1/batch-action` | Batch power action, delete, or reinstall |
|
||||||
|
| POST | `/api/v1/ssh-ticket` | Create WebSSH ticket |
|
||||||
|
| POST | `/api/v1/vnc-ticket` | Create WebVNC ticket |
|
||||||
|
|
||||||
|
### Accounts and Logs
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| POST | `/api/v1/sub-user/create` | Create sub-user link |
|
||||||
|
| GET | `/api/v1/sub-users` | Sub-user list |
|
||||||
|
| POST | `/api/v1/sub-users/{id}/rotate-password` | Rotate sub-user password |
|
||||||
|
| GET | `/api/v1/sub-users/{id}/audit-logs` | Sub-user audit logs |
|
||||||
|
| GET | `/api/v1/sub-users/{id}/login-logs` | Sub-user login logs |
|
||||||
|
| GET | `/api/v1/audit-logs` | Audit logs |
|
||||||
|
| GET | `/api/v1/login-logs` | Login logs |
|
||||||
|
| GET | `/api/v1/api-keys` | API key list |
|
||||||
|
| POST | `/api/v1/api-keys` | Create API key |
|
||||||
|
| PATCH | `/api/v1/api-keys/{id}` | Update API key |
|
||||||
|
| DELETE | `/api/v1/api-keys/{id}` | Delete API key |
|
||||||
|
|
||||||
|
## Response Samples
|
||||||
|
|
||||||
|
The samples below are grouped by endpoint path. Resource numbers, task IDs, container IDs, timestamps, IP addresses, and keys will differ in real environments. Passwords, tickets, and API keys are masked.
|
||||||
|
|
||||||
|
### Overview
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"GET /api/v1/dashboard": {
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"running": 31,
|
||||||
|
"stopped": 0,
|
||||||
|
"total_containers": 31
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"GET /api/v1/host-info": {
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"cpu": { "cores": 8, "usage_pct": 1.16 },
|
||||||
|
"ram": { "total_mb": 31825, "used_mb": 1275, "free_mb": 30550 },
|
||||||
|
"disk": { "total_gb": 1750.49, "used_gb": 123.98, "free_gb": 1626.51 },
|
||||||
|
"network": {
|
||||||
|
"public_ipv4": "203.0.113.10",
|
||||||
|
"public_ipv4_interface": "eth0",
|
||||||
|
"public_ipv6": "2001:db8:100::2",
|
||||||
|
"public_ipv6_interface": "eth0"
|
||||||
|
},
|
||||||
|
"load": { "load1": 0.01, "load5": 0.03, "load15": 0.01 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"GET /api/v1/routing": {
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"nat4": { "used": 62, "remaining": "45474", "total": "45536" },
|
||||||
|
"ipv4": { "used": 1, "remaining": "3", "total": "4" },
|
||||||
|
"ipv6": { "used": 31, "remaining": "large", "total": "large" },
|
||||||
|
"public_ipv4_addresses": [
|
||||||
|
{ "address": "203.0.113.10", "interface": "eth0", "prefix_len": 32, "gateway": "203.0.113.1" }
|
||||||
|
],
|
||||||
|
"ipv4_assignments": [
|
||||||
|
{ "container_id": 5, "container_name": "example-vm", "address": "203.0.113.10", "interface": "eth0", "prefix_len": 32, "gateway": "203.0.113.1" }
|
||||||
|
],
|
||||||
|
"nat4_mappings": [
|
||||||
|
{ "container_id": 5, "container_name": "example-vm", "status": "running", "ip": "10.0.0.10", "host_port": 22004, "container_port": 22, "protocol": "tcp" }
|
||||||
|
],
|
||||||
|
"ipv6_assignments": [
|
||||||
|
{ "container_id": 5, "container_name": "example-vm", "address": "2001:db8:100::1005", "prefix_len": 64, "interface": "eth0" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"PUT /api/v1/routing": {
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"ipv4": { "used": 1, "remaining": "3", "total": "4" },
|
||||||
|
"public_ipv4_addresses": [
|
||||||
|
{ "address": "203.0.113.10", "interface": "eth0", "prefix_len": 32, "gateway": "203.0.113.1" }
|
||||||
|
],
|
||||||
|
"ipv6_prefixes": [
|
||||||
|
{ "interface": "eth0", "address": "2001:db8:100::2", "prefix": "2001:db8:100::/64", "prefix_len": 64, "gateway": "2001:db8:100::1" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"POST /api/v1/routing/ipv4-scan": {
|
||||||
|
"success": true,
|
||||||
|
"data": [
|
||||||
|
{ "address": "203.0.113.10", "interface": "eth0", "prefix_len": 32, "gateway": "203.0.113.1", "status": "available", "usable": true, "reason": "" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"GET /api/v1/ipv6/status": {
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"available": true,
|
||||||
|
"reachable": true,
|
||||||
|
"reason": "usable public IPv6 prefix detected",
|
||||||
|
"prefixes": [
|
||||||
|
{ "interface": "eth0", "address": "2001:db8:100::2", "prefix": "2001:db8:100::/64", "prefix_len": 64, "gateway": "2001:db8:100::1" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"GET /api/v1/tasks": {
|
||||||
|
"success": true,
|
||||||
|
"data": []
|
||||||
|
},
|
||||||
|
"DELETE /api/v1/tasks/{task_id}": {
|
||||||
|
"success": true,
|
||||||
|
"message": "Task deleted"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Containers
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"GET /api/v1/containers": {
|
||||||
|
"success": true,
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"id": 5,
|
||||||
|
"uuid": "00000000-0000-4000-8000-000000000005",
|
||||||
|
"name": "example-vm",
|
||||||
|
"virtualization": "lxc",
|
||||||
|
"template": "debian-bullseye",
|
||||||
|
"vcpu": 1,
|
||||||
|
"ram_mb": 512,
|
||||||
|
"disk_gb": 10,
|
||||||
|
"status": "running",
|
||||||
|
"ip": "10.0.0.10",
|
||||||
|
"ipv6": "2001:db8:100::1005",
|
||||||
|
"ssh_port": 22004,
|
||||||
|
"ssh_password": "***",
|
||||||
|
"port_mappings": [
|
||||||
|
{ "container_port": 22, "host_port": 22004, "protocol": "tcp", "description": "SSH" },
|
||||||
|
{ "container_port": 20000, "host_port": 20000, "protocol": "tcp", "description": "Port-20000" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"POST /api/v1/containers/list": {
|
||||||
|
"success": true,
|
||||||
|
"data": [
|
||||||
|
{ "id": 5, "uuid": "00000000-0000-4000-8000-000000000005", "name": "example-vm", "status": "running", "ip": "10.0.0.10" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"POST /api/v1/containers": {
|
||||||
|
"success": true,
|
||||||
|
"message": "Container created successfully"
|
||||||
|
},
|
||||||
|
"GET /api/v1/containers/{id|uuid|name}": {
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"id": 5,
|
||||||
|
"uuid": "00000000-0000-4000-8000-000000000005",
|
||||||
|
"name": "example-vm",
|
||||||
|
"status": "running",
|
||||||
|
"ip": "10.0.0.10",
|
||||||
|
"ipv6": "2001:db8:100::1005",
|
||||||
|
"ssh_port": 22004,
|
||||||
|
"ssh_password": "***",
|
||||||
|
"policy_blocked": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"POST /api/v1/containers/{id}/start": {
|
||||||
|
"success": true,
|
||||||
|
"message": "Task queued",
|
||||||
|
"data": { "task_id": "task-10", "container_name": "example-vm", "status": "pending", "action": "start" }
|
||||||
|
},
|
||||||
|
"POST /api/v1/containers/{id}/stop": {
|
||||||
|
"success": true,
|
||||||
|
"message": "Task queued",
|
||||||
|
"data": { "task_id": "task-10", "container_name": "example-vm", "status": "pending", "action": "stop" }
|
||||||
|
},
|
||||||
|
"POST /api/v1/containers/{id}/restart": {
|
||||||
|
"success": true,
|
||||||
|
"message": "Task queued",
|
||||||
|
"data": { "task_id": "task-10", "container_name": "example-vm", "status": "pending", "action": "restart" }
|
||||||
|
},
|
||||||
|
"POST /api/v1/containers/{id}/reinstall": {
|
||||||
|
"success": true,
|
||||||
|
"message": "Task queued",
|
||||||
|
"data": { "task_id": "task-10", "container_name": "example-vm", "status": "pending", "action": "reinstall" }
|
||||||
|
},
|
||||||
|
"DELETE /api/v1/containers/{id}/delete": {
|
||||||
|
"success": true,
|
||||||
|
"message": "Task queued",
|
||||||
|
"data": { "task_id": "task-10", "container_name": "example-vm", "status": "pending", "action": "delete" }
|
||||||
|
},
|
||||||
|
"GET /api/v1/containers/{id}/usage": {
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"cpu_usage_pct": 0,
|
||||||
|
"cpu_usage_usec": 3908852,
|
||||||
|
"memory_usage_bytes": 29331456,
|
||||||
|
"disk_usage_bytes": 515100672,
|
||||||
|
"network_rx_bytes": 131232,
|
||||||
|
"network_tx_bytes": 16828,
|
||||||
|
"load1": 0.1,
|
||||||
|
"load5": 0.06,
|
||||||
|
"load15": 0.01
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"GET /api/v1/containers/{id}/traffic": {
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"mode": "total",
|
||||||
|
"limit_gb": 0,
|
||||||
|
"in_limit_gb": 0,
|
||||||
|
"out_limit_gb": 0,
|
||||||
|
"total_used_bytes": 142082,
|
||||||
|
"rx_used_bytes": 127212,
|
||||||
|
"tx_used_bytes": 14870,
|
||||||
|
"used_pct": 0,
|
||||||
|
"reset_date": "2026-06"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"POST /api/v1/containers/{id}/traffic-reset": {
|
||||||
|
"success": true,
|
||||||
|
"message": "Traffic reset"
|
||||||
|
},
|
||||||
|
"PUT /api/v1/containers/{id}/traffic-limit": {
|
||||||
|
"success": true,
|
||||||
|
"message": "Traffic limit updated"
|
||||||
|
},
|
||||||
|
"PUT /api/v1/containers/{id}/resource-limit": {
|
||||||
|
"success": true,
|
||||||
|
"message": "Resource limits updated"
|
||||||
|
},
|
||||||
|
"PUT /api/v1/containers/{id}/expiry": {
|
||||||
|
"success": true,
|
||||||
|
"message": "Expiry updated"
|
||||||
|
},
|
||||||
|
"POST /api/v1/containers/{id}/reset-password": {
|
||||||
|
"success": true,
|
||||||
|
"message": "SSH password reset successfully",
|
||||||
|
"data": { "password": "***" }
|
||||||
|
},
|
||||||
|
"POST /api/v1/containers/{id}/ipv6": {
|
||||||
|
"success": true,
|
||||||
|
"message": "IPv6 assigned",
|
||||||
|
"data": { "id": 5, "name": "example-vm", "ipv6": "2001:db8:100::1005" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ports and Snapshots
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"GET /api/v1/containers/{id}/random-port": {
|
||||||
|
"success": true,
|
||||||
|
"data": { "port": 61320 }
|
||||||
|
},
|
||||||
|
"POST /api/v1/containers/{id}/port-mappings": {
|
||||||
|
"success": true,
|
||||||
|
"data": [
|
||||||
|
{ "container_port": 22, "host_port": 22004, "protocol": "tcp", "description": "SSH" },
|
||||||
|
{ "container_port": 8080, "host_port": 61320, "protocol": "tcp", "description": "HTTP" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"PUT /api/v1/containers/{id}/port-mappings/{index}": {
|
||||||
|
"success": true,
|
||||||
|
"data": [
|
||||||
|
{ "container_port": 8081, "host_port": 61320, "protocol": "tcp", "description": "HTTP" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"DELETE /api/v1/containers/{id}/port-mappings/{index}": {
|
||||||
|
"success": true,
|
||||||
|
"data": []
|
||||||
|
},
|
||||||
|
"GET /api/v1/snapshots": {
|
||||||
|
"success": true,
|
||||||
|
"data": null
|
||||||
|
},
|
||||||
|
"GET /api/v1/containers/{id}/snapshots": {
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"quota": 1,
|
||||||
|
"schedule": { "enabled": false, "interval_hours": 0, "last_run": "", "next_run": "", "time": "", "created_by": "" },
|
||||||
|
"snapshots": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"POST /api/v1/containers/{id}/snapshots": {
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"id": "snap-20260608-001",
|
||||||
|
"container_id": 5,
|
||||||
|
"container_name": "example-vm",
|
||||||
|
"created_at": "2026-06-08 16:00:00",
|
||||||
|
"created_by": "api:Automation",
|
||||||
|
"scheduled": false,
|
||||||
|
"size_bytes": 10485760
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"DELETE /api/v1/containers/{id}/snapshots/{snapshot_id}": {
|
||||||
|
"success": true,
|
||||||
|
"message": "Snapshot deleted"
|
||||||
|
},
|
||||||
|
"POST /api/v1/containers/{id}/snapshots/{snapshot_id}/restore": {
|
||||||
|
"success": true,
|
||||||
|
"message": "Snapshot restored"
|
||||||
|
},
|
||||||
|
"POST /api/v1/containers/{id}/snapshots/schedule": {
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"container": { "id": 5, "name": "example-vm", "snapshot_schedule_enabled": true, "snapshot_schedule_interval_hours": 24, "snapshot_schedule_time": "03:00" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"PUT /api/v1/containers/{id}/snapshots/quota": {
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"quota": 2,
|
||||||
|
"container": { "id": 5, "name": "example-vm", "snapshot_limit": 2 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Platform Management
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"GET /api/v1/templates": {
|
||||||
|
"success": true,
|
||||||
|
"data": [
|
||||||
|
{ "id": "ubuntu-noble", "name": "Ubuntu 24.04", "distro": "ubuntu", "release": "noble", "arch": "amd64", "description": "Ubuntu 24.04 LTS" },
|
||||||
|
{ "id": "debian-bookworm", "name": "Debian 12", "distro": "debian", "release": "bookworm", "arch": "amd64", "description": "Debian 12 (Bookworm)" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"GET /api/v1/images": {
|
||||||
|
"success": true,
|
||||||
|
"data": [
|
||||||
|
{ "id": "ubuntu-noble", "name": "Ubuntu 24.04", "type": "lxc", "downloaded": true, "enabled": true, "downloading": false, "progress": 0, "size_bytes": 135005452 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"POST /api/v1/images/download": {
|
||||||
|
"success": true,
|
||||||
|
"message": "Already downloaded"
|
||||||
|
},
|
||||||
|
"POST /api/v1/images/cancel": {
|
||||||
|
"success": true,
|
||||||
|
"message": "Cancel requested"
|
||||||
|
},
|
||||||
|
"DELETE /api/v1/images/delete": {
|
||||||
|
"success": true,
|
||||||
|
"message": "Deleted"
|
||||||
|
},
|
||||||
|
"PUT /api/v1/images/toggle": {
|
||||||
|
"success": true,
|
||||||
|
"message": "OK"
|
||||||
|
},
|
||||||
|
"GET /api/v1/security/alerts": {
|
||||||
|
"success": true,
|
||||||
|
"data": []
|
||||||
|
},
|
||||||
|
"POST /api/v1/security/check": {
|
||||||
|
"success": true,
|
||||||
|
"message": "Security check completed"
|
||||||
|
},
|
||||||
|
"GET /api/v1/security/logs?container={name}": {
|
||||||
|
"success": true,
|
||||||
|
"data": []
|
||||||
|
},
|
||||||
|
"GET /api/v1/security/summary": {
|
||||||
|
"success": true,
|
||||||
|
"data": { "critical": 0, "high": 0, "medium": 0, "low": 0, "total_alerts": 0 }
|
||||||
|
},
|
||||||
|
"GET /api/v1/security/settings": {
|
||||||
|
"success": true,
|
||||||
|
"data": { "auto_shutdown": false }
|
||||||
|
},
|
||||||
|
"PUT /api/v1/security/settings": {
|
||||||
|
"success": true,
|
||||||
|
"data": { "auto_shutdown": false }
|
||||||
|
},
|
||||||
|
"GET /api/v1/swap": {
|
||||||
|
"success": true,
|
||||||
|
"data": { "total_mb": 16383, "used_mb": 0, "free_mb": 16383, "enabled": true, "swap_file": "/swapfile" }
|
||||||
|
},
|
||||||
|
"POST /api/v1/swap": {
|
||||||
|
"success": true,
|
||||||
|
"message": "SWAP 已调整为 16384 MB",
|
||||||
|
"data": { "total_mb": 16383, "used_mb": 0, "free_mb": 16383, "enabled": true, "swap_file": "/swapfile" }
|
||||||
|
},
|
||||||
|
"POST /api/v1/batch-create": {
|
||||||
|
"success": true,
|
||||||
|
"data": ["task-12"]
|
||||||
|
},
|
||||||
|
"POST /api/v1/batch-action": {
|
||||||
|
"success": true,
|
||||||
|
"data": ["task-13"]
|
||||||
|
},
|
||||||
|
"POST /api/v1/ssh-ticket": {
|
||||||
|
"success": true,
|
||||||
|
"data": { "ticket": "***60 seconds valid***" }
|
||||||
|
},
|
||||||
|
"POST /api/v1/vnc-ticket": {
|
||||||
|
"success": true,
|
||||||
|
"data": { "ticket": "***60 seconds valid***" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Accounts and Logs
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"POST /api/v1/sub-user/create": {
|
||||||
|
"success": true,
|
||||||
|
"message": "Sub-user created",
|
||||||
|
"data": {
|
||||||
|
"id": "sub-xxxxxxxx",
|
||||||
|
"username": "user-xxxxxxxx",
|
||||||
|
"password": "***",
|
||||||
|
"container_names": ["example-vm"],
|
||||||
|
"access_code": "********",
|
||||||
|
"created_at": "2026-06-08 16:00:00"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"GET /api/v1/sub-users": {
|
||||||
|
"success": true,
|
||||||
|
"data": []
|
||||||
|
},
|
||||||
|
"POST /api/v1/sub-users/{id}/rotate-password": {
|
||||||
|
"success": true,
|
||||||
|
"data": { "username": "user-xxxxxxxx", "password": "***", "access_code": "********" }
|
||||||
|
},
|
||||||
|
"GET /api/v1/sub-users/{id}/audit-logs": {
|
||||||
|
"success": true,
|
||||||
|
"data": []
|
||||||
|
},
|
||||||
|
"GET /api/v1/sub-users/{id}/login-logs": {
|
||||||
|
"success": true,
|
||||||
|
"data": []
|
||||||
|
},
|
||||||
|
"GET /api/v1/audit-logs": {
|
||||||
|
"success": true,
|
||||||
|
"data": [
|
||||||
|
{ "time": "2026-06-08 15:44:40", "action": "apikey.create", "target": "Test", "detail": "scopes=*", "user": "admin", "success": true }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"GET /api/v1/login-logs": {
|
||||||
|
"success": true,
|
||||||
|
"data": [
|
||||||
|
{ "time": "2026-06-08 08:24:00 UTC", "username": "admin", "ip": "198.51.100.23", "user_agent": "Mozilla/5.0 ...", "success": true }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"GET /api/v1/api-keys": {
|
||||||
|
"success": true,
|
||||||
|
"data": [
|
||||||
|
{ "id": "c271023f", "name": "Test", "prefix": "clicd_sk_dd9d...", "ip_whitelist": "", "created_at": "2026-06-08 15:44:40", "last_used": "2026-06-08 15:46:10", "scopes": ["*"], "last_used_ip": "198.51.100.23" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"POST /api/v1/api-keys": {
|
||||||
|
"success": true,
|
||||||
|
"message": "API key created. Save this key now - it won't be shown again.",
|
||||||
|
"data": { "id": "a1b2c3d4", "name": "Automation", "key": "clicd_sk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", "prefix": "clicd_sk_xxxx...", "scopes": ["dashboard:read", "container:read"] }
|
||||||
|
},
|
||||||
|
"PATCH /api/v1/api-keys/{id}": {
|
||||||
|
"success": true,
|
||||||
|
"data": { "id": "a1b2c3d4", "name": "Automation", "prefix": "clicd_sk_xxxx...", "scopes": ["dashboard:read", "container:read"], "disabled": false }
|
||||||
|
},
|
||||||
|
"DELETE /api/v1/api-keys/{id}": {
|
||||||
|
"success": true,
|
||||||
|
"message": "API key deleted"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# Container Management
|
||||||
|
|
||||||
|
Container Management is the core CLICD module. It covers creation, lifecycle operations, resource limits, network mappings, traffic statistics, password resets, and console access.
|
||||||
|
|
||||||
|
## Container List
|
||||||
|
|
||||||
|
The list page scans container status. Administrators can view all containers. Sub-users only see containers within their authorization scope.
|
||||||
|
|
||||||
|
Common fields include:
|
||||||
|
|
||||||
|
- ID, UUID, and name.
|
||||||
|
- Virtualization type.
|
||||||
|
- Runtime status.
|
||||||
|
- IP and IPv6.
|
||||||
|
- CPU, memory, and disk limits.
|
||||||
|
- Traffic usage and traffic limits.
|
||||||
|
- Expiration time.
|
||||||
|
|
||||||
|
## Create Containers
|
||||||
|
|
||||||
|
Creation requires a template and resource quotas. Batch creation is available from the panel or API and is useful for issuing multiple containers at once.
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/v1/containers
|
||||||
|
POST /api/v1/batch-create
|
||||||
|
```
|
||||||
|
|
||||||
|
Linux containers and Linux KVM virtual machines support SSH login configuration during creation:
|
||||||
|
|
||||||
|
- `auto_password`: generate a root SSH password automatically.
|
||||||
|
- `password`: use a custom `ssh_password`.
|
||||||
|
- `key`: write a one-line `ssh_public_key`; a password is still kept for WebSSH.
|
||||||
|
|
||||||
|
Network allocation can combine NAT, public IPv4, and IPv6 as needed. API fields such as `assign_nat`, `assign_ipv4`, `public_ipv4s`, `assign_ipv6`, and `ipv6_addresses` are optional. If they are omitted, default behavior is preserved.
|
||||||
|
|
||||||
|
## Lifecycle Operations
|
||||||
|
|
||||||
|
```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
|
||||||
|
```
|
||||||
|
|
||||||
|
Start, stop, reinstall, and delete actions enter the task queue. Call `GET /api/v1/tasks` afterwards to check execution status.
|
||||||
|
|
||||||
|
When reinstalling a Linux system, you may pass `ssh_auth_mode`, `ssh_password`, and `ssh_public_key`. `ssh_auth_mode=keep` keeps the current SSH password. If these fields are omitted, the old behavior is preserved.
|
||||||
|
|
||||||
|
## Resources and Traffic
|
||||||
|
|
||||||
|
The container details page supports resource usage, traffic limit changes, resource limit changes, and expiration changes.
|
||||||
|
|
||||||
|
```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 Port Management
|
||||||
|
|
||||||
|
The NAT port management section supports adding, editing, and deleting mappings. Add and edit actions use a dialog so name, protocol, external port, and internal port can be filled in together.
|
||||||
|
|
||||||
|
```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}
|
||||||
|
```
|
||||||
|
|
||||||
|
In sub-user mode, administrators can limit sub-users to changing only the internal port, preventing changes to the host-facing port and protocol.
|
||||||
|
|
||||||
|
## Remote Console
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/v1/ssh-ticket
|
||||||
|
POST /api/v1/vnc-ticket
|
||||||
|
```
|
||||||
|
|
||||||
|
Tickets are short-lived. Use them immediately for WebSSH or WebVNC and do not persist them.
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Dashboard
|
||||||
|
|
||||||
|
The dashboard shows the overall state of the host and virtualization resources.
|
||||||
|
|
||||||
|
## Metrics
|
||||||
|
|
||||||
|
- Total containers, running containers, and stopped containers.
|
||||||
|
- CPU, memory, disk, and Swap overview.
|
||||||
|
- Entry points for host network and routing status.
|
||||||
|
- Task queue status.
|
||||||
|
- Security alert summary.
|
||||||
|
|
||||||
|
## Related APIs
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/v1/dashboard
|
||||||
|
GET /api/v1/host-info
|
||||||
|
GET /api/v1/routing
|
||||||
|
GET /api/v1/ipv6/status
|
||||||
|
GET /api/v1/tasks
|
||||||
|
```
|
||||||
|
|
||||||
|
API requests must include an API key:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -H "X-API-Key: YOUR_API_KEY" https://panel.example.com/api/v1/dashboard
|
||||||
|
```
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Host Report
|
||||||
|
|
||||||
|
The host report summarizes the host runtime environment, resource status, and virtualization dependencies. It is useful for post-installation checks, troubleshooting, or sharing environment information with maintainers.
|
||||||
|
|
||||||
|
## Contents
|
||||||
|
|
||||||
|
- System version and kernel information.
|
||||||
|
- CPU, memory, disk, and Swap.
|
||||||
|
- Network status.
|
||||||
|
- LXC/KVM dependency status.
|
||||||
|
- CLICD service status.
|
||||||
|
|
||||||
|
## Related APIs
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/v1/host-report
|
||||||
|
GET /api/v1/host-info
|
||||||
|
GET /api/v1/swap
|
||||||
|
```
|
||||||
|
|
||||||
|
Before sending a report externally, check whether it contains public IPs, private networks, usernames, keys, tickets, or business domains.
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# Image Management
|
||||||
|
|
||||||
|
Image Management maintains templates used to create containers or virtual machines.
|
||||||
|
|
||||||
|
## Supported Template Types
|
||||||
|
|
||||||
|
The project includes common Linux distribution templates such as Debian, Ubuntu, Alpine, CentOS, Fedora, Arch Linux, and Rocky Linux. KVM templates use the corresponding distribution cloud image resources.
|
||||||
|
|
||||||
|
## Management Actions
|
||||||
|
|
||||||
|
```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` returns available template definitions.
|
||||||
|
- `images` returns local image status.
|
||||||
|
- `download` downloads a specific template.
|
||||||
|
- `cancel` cancels a download task.
|
||||||
|
- `delete` removes the local image cache.
|
||||||
|
- `toggle` controls whether a template can be used during creation.
|
||||||
|
|
||||||
|
## Windows Images
|
||||||
|
|
||||||
|
This project does not distribute Windows system images and does not provide features to bypass or avoid Windows activation. Windows download links should point to official Microsoft resources, and users must obtain valid licenses themselves.
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# Networking and Routing
|
||||||
|
|
||||||
|
CLICD provides NAT4 port mapping, random available ports, public IPv4 assignment, IPv6 status checks, and IPv6 assignment. During container creation, you can use NAT only, public IPv4 only, IPv6 only, or a mixed network setup.
|
||||||
|
|
||||||
|
## NAT4
|
||||||
|
|
||||||
|
NAT4 forwards host ports to container internal ports. Common uses include:
|
||||||
|
|
||||||
|
- Forwarding SSH.
|
||||||
|
- Exposing web services.
|
||||||
|
- Assigning fixed external ports to sub-users.
|
||||||
|
|
||||||
|
Port mappings include:
|
||||||
|
|
||||||
|
| Field | Description |
|
||||||
|
| --- | --- |
|
||||||
|
| Name | A purpose label such as `ssh` or `web`. |
|
||||||
|
| Protocol | `tcp` or `udp`. |
|
||||||
|
| External port | The host port exposed to the outside. |
|
||||||
|
| Internal port | The service port inside the container. |
|
||||||
|
|
||||||
|
## IPv6
|
||||||
|
|
||||||
|
IPv6 assignment requires the host to have a routable IPv6 prefix, plus correct routing, neighbor discovery, or proxy configuration.
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/v1/ipv6/status
|
||||||
|
POST /api/v1/containers/{id}/ipv6
|
||||||
|
```
|
||||||
|
|
||||||
|
If the host has no public IPv6 or the upstream network is not routing the prefix correctly, assigned addresses will not be reachable from the public internet.
|
||||||
|
|
||||||
|
## Public IPv4
|
||||||
|
|
||||||
|
Public IPv4 assignment selects from public IPv4 addresses detected on the host, or from `public_ipv4s` specified through the API. Creation fields include:
|
||||||
|
|
||||||
|
| Field | Description |
|
||||||
|
| --- | --- |
|
||||||
|
| `assign_nat` | Whether to enable NAT port mappings. |
|
||||||
|
| `assign_ipv4` | Whether to assign public IPv4. |
|
||||||
|
| `ipv4_count` | Number of public IPv4 addresses to allocate automatically. |
|
||||||
|
| `public_ipv4s` | Explicit public IPv4 address list. |
|
||||||
|
| `assign_ipv6` | Whether to assign IPv6. |
|
||||||
|
| `ipv6_count` | Number of IPv6 addresses to allocate automatically. |
|
||||||
|
| `ipv6_addresses` | Explicit IPv6 address list. |
|
||||||
|
|
||||||
|
Public address pool APIs:
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/v1/routing
|
||||||
|
PUT /api/v1/routing
|
||||||
|
POST /api/v1/routing/ipv4-scan
|
||||||
|
```
|
||||||
|
|
||||||
|
## Routing Status
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/v1/routing
|
||||||
|
```
|
||||||
|
|
||||||
|
This endpoint shows runtime status for NAT, IPv4, IPv6, and port capacity.
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# Security Alerts
|
||||||
|
|
||||||
|
CLICD includes lightweight security alerts based on connection behavior. It does not keep full normal connection logs; it focuses on abnormal behavior and high-risk patterns.
|
||||||
|
|
||||||
|
## Covered Scenarios
|
||||||
|
|
||||||
|
- Port scanning.
|
||||||
|
- Lateral scanning.
|
||||||
|
- Brute-force tendencies.
|
||||||
|
- SMTP abuse.
|
||||||
|
- UDP reflection risk.
|
||||||
|
- Suspicious ports related to mining, proxies, VPNs, Tor, and similar services.
|
||||||
|
|
||||||
|
## APIs
|
||||||
|
|
||||||
|
```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
|
||||||
|
```
|
||||||
|
|
||||||
|
## Automatic Shutdown
|
||||||
|
|
||||||
|
Security settings can enable automatic shutdown after alerts. Before enabling it, observe for a while and make sure the rules do not affect normal services.
|
||||||
|
|
||||||
|
## Logging Advice
|
||||||
|
|
||||||
|
Security alerts are risk signals. They should not replace a professional firewall, intrusion detection, or centralized logging system. For public services, still combine them with security groups, firewall rules, Fail2ban, and similar tools.
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# Snapshot Management
|
||||||
|
|
||||||
|
Snapshots save the current state of a container so it can be rolled back before upgrades, configuration changes, or delivery.
|
||||||
|
|
||||||
|
## Global Overview
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/v1/snapshots
|
||||||
|
```
|
||||||
|
|
||||||
|
Use this endpoint to view snapshot summaries for all containers.
|
||||||
|
|
||||||
|
## Container 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
|
||||||
|
```
|
||||||
|
|
||||||
|
Restoring a snapshot changes container state. In production, confirm that the current workload can be interrupted first.
|
||||||
|
|
||||||
|
## Scheduled Snapshots and Quotas
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/v1/containers/{id}/snapshots/schedule
|
||||||
|
PUT /api/v1/containers/{id}/snapshots/quota
|
||||||
|
```
|
||||||
|
|
||||||
|
Scheduled snapshots are useful for long-running containers. Quotas prevent snapshots from growing without limit and filling the host disk.
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Sub-users
|
||||||
|
|
||||||
|
Sub-users let administrators grant specific container access to other users. They are useful for temporary delivery, shared-host allocation, teaching labs, or multi-user host scenarios.
|
||||||
|
|
||||||
|
## Create an Access Link
|
||||||
|
|
||||||
|
After selecting a container, the administrator can create a sub-user link:
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/v1/sub-user/create
|
||||||
|
```
|
||||||
|
|
||||||
|
The response may include a username, initial password, access code, or access link. When sharing externally, mask sensitive values and send the real values only to the intended user.
|
||||||
|
|
||||||
|
## Manage Sub-users
|
||||||
|
|
||||||
|
```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
|
||||||
|
```
|
||||||
|
|
||||||
|
Rotating the password invalidates old credentials. Audit logs and login logs help investigate mistakes or abnormal access.
|
||||||
|
|
||||||
|
## Permission Scope
|
||||||
|
|
||||||
|
Sub-users can only manage authorized containers. Global configuration, image management, security policy, API keys, and other administrator features are not exposed to sub-users.
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# Configuration
|
||||||
|
|
||||||
|
After installation, CLICD runs as a systemd service. Runtime configuration and the database are stored locally on the host. The exact path may vary with installer options, but the default installation should mainly be checked under `/root/.clicd/`.
|
||||||
|
|
||||||
|
## Common Settings
|
||||||
|
|
||||||
|
| Setting | Description |
|
||||||
|
| --- | --- |
|
||||||
|
| Web port | Defaults to `8999`, listening on `0.0.0.0:8999`. |
|
||||||
|
| Administrator account | Used to log in to the web panel and manage API keys. |
|
||||||
|
| Database | SQLite storage for container metadata, sub-users, audit logs, API keys, and more. |
|
||||||
|
| NAT port range | Used for random ports and port mapping allocation. |
|
||||||
|
| IPv6 prefixes | Used when the host has routable IPv6 prefixes. |
|
||||||
|
| Security alerts | Policies such as automatic shutdown can be configured. |
|
||||||
|
|
||||||
|
## Service Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl status clicd
|
||||||
|
systemctl restart clicd
|
||||||
|
journalctl -u clicd -n 100 --no-pager
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security Recommendations
|
||||||
|
|
||||||
|
- Do not expose the web panel directly to untrusted networks.
|
||||||
|
- Use a strong administrator password and rotate it regularly.
|
||||||
|
- Split API keys by purpose and avoid long-lived full-access keys.
|
||||||
|
- WebSSH and WebVNC tickets are short-lived credentials and should not be written to logs or shared publicly.
|
||||||
|
- Do not paste real IPs, passwords, API keys, or tickets into public docs, screenshots, or support tickets.
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# Installation
|
||||||
|
|
||||||
|
CLICD provides a one-line installer. By default, it installs the latest version from GitHub Releases. You can also pin a specific version with an environment variable.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Linux x86_64 host.
|
||||||
|
- Root privileges.
|
||||||
|
- systemd.
|
||||||
|
- Network access to GitHub Release downloads.
|
||||||
|
- LXC runtime support if you want to use LXC.
|
||||||
|
- KVM virtualization enabled with libvirt/QEMU installed if you want to use KVM.
|
||||||
|
|
||||||
|
## Install the Latest Version
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://raw.githubusercontent.com/MengMengCode/CLICD/main/install.sh | sudo sh
|
||||||
|
```
|
||||||
|
|
||||||
|
The script defaults to `CLICD_VERSION=latest`, which downloads `clicd-linux-amd64.tar.gz` from `releases/latest`.
|
||||||
|
|
||||||
|
## Install a Specific Version
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://raw.githubusercontent.com/MengMengCode/CLICD/main/install.sh | sudo CLICD_VERSION=v1.1.6 sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace `v1.1.6` with the release tag you want to install.
|
||||||
|
|
||||||
|
## Open the Panel
|
||||||
|
|
||||||
|
After installation, open:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://YOUR_SERVER_IP:8999
|
||||||
|
```
|
||||||
|
|
||||||
|
Use the administrator credentials printed by the installer for the first login. In production, restrict access at the firewall or reverse proxy layer and change the default username and password as soon as possible.
|
||||||
|
|
||||||
|
## Uninstall
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://raw.githubusercontent.com/MengMengCode/CLICD/main/install.sh | sudo sh -s -- uninstall
|
||||||
|
```
|
||||||
|
|
||||||
|
Before uninstalling, decide whether you need to keep containers, image cache, database files, or configuration files.
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# Introduction
|
||||||
|
|
||||||
|
CLICD is a lightweight virtualization management panel for LXC and KVM. It brings common host operations into a web console and CLI, making it suitable for small VPS nodes, dedicated servers, and scenarios where container access needs to be distributed in batches.
|
||||||
|
|
||||||
|
## Core Capabilities
|
||||||
|
|
||||||
|
- Manage LXC containers and KVM virtual machines.
|
||||||
|
- Create, start, stop, restart, reinstall, and delete containers.
|
||||||
|
- Configure CPU, memory, disk, traffic limits, and expiration time.
|
||||||
|
- Manage NAT4 port mappings, public IPv4 assignment, and public IPv6 assignment when the host network supports it.
|
||||||
|
- Open WebSSH or WebVNC from the browser.
|
||||||
|
- Manage image downloads, enablement, and local cache.
|
||||||
|
- Create, restore, and delete snapshots, plus scheduled snapshots and quotas.
|
||||||
|
- Generate security alerts based on connection behavior and keep audit logs.
|
||||||
|
- Create sub-user access links for specific containers.
|
||||||
|
- Integrate automation through API keys and `/api/v1`.
|
||||||
|
|
||||||
|
## Use Cases
|
||||||
|
|
||||||
|
- Quickly allocate multiple Linux containers on one host.
|
||||||
|
- Give users temporary access to a container console, SSH, VNC, or NAT port management.
|
||||||
|
- Automate container creation, resource changes, password resets, or resource cleanup through the API.
|
||||||
|
- Use a panel that is clearer than pure CLI workflows without becoming a heavy platform.
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- Backend: Go, `net/http`, SQLite, systemd, LXC, KVM/libvirt, cgroup v2, iptables, conntrack.
|
||||||
|
- Frontend: React, TypeScript, Vite, Tailwind CSS, lucide-react, xterm.js, noVNC.
|
||||||
|
- Release: GitHub Actions builds Linux AMD64 release artifacts. The installer fetches the latest release by default.
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# Quick Start
|
||||||
|
|
||||||
|
This is a common path from a fresh installation to your first container.
|
||||||
|
|
||||||
|
## 1. Log In
|
||||||
|
|
||||||
|
Open `http://YOUR_SERVER_IP:8999` and sign in with the administrator account.
|
||||||
|
|
||||||
|
After entering the panel, check:
|
||||||
|
|
||||||
|
- Whether the dashboard shows host resources.
|
||||||
|
- Whether Image Management can list templates.
|
||||||
|
- Whether NAT and IPv6 status in Routing match your host network.
|
||||||
|
|
||||||
|
## 2. Download an Image
|
||||||
|
|
||||||
|
Open Image Management, choose a template, and download it. On small hosts, lightweight images such as Alpine or Debian are a good first choice.
|
||||||
|
|
||||||
|
Image downloads run asynchronously. You can watch progress in the task queue.
|
||||||
|
|
||||||
|
## 3. Create a Container
|
||||||
|
|
||||||
|
Open Container Management and click Create:
|
||||||
|
|
||||||
|
- Select virtualization type and template.
|
||||||
|
- Set CPU, memory, and disk.
|
||||||
|
- Set traffic limits and expiration time.
|
||||||
|
- If external access is required, add NAT port mappings or assign IPv6 from the container details page after creation.
|
||||||
|
|
||||||
|
## 4. Open a Terminal
|
||||||
|
|
||||||
|
After the container is created, open WebSSH from the details page. KVM virtual machines can use WebVNC for console access.
|
||||||
|
|
||||||
|
## 5. Share with a Sub-user
|
||||||
|
|
||||||
|
If another user needs to manage a container, create an access link in Sub-user Management. The sub-user only sees authorized containers and is limited by the operation scope configured by the administrator.
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Upgrade
|
||||||
|
|
||||||
|
The CLICD installer and CLI are built around GitHub Release artifacts. Before upgrading, check the current version and back up configuration and database files.
|
||||||
|
|
||||||
|
## Check the Version
|
||||||
|
|
||||||
|
The current version is shown at the bottom of the web panel sidebar. You can also run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://127.0.0.1:8999/api/version
|
||||||
|
```
|
||||||
|
|
||||||
|
Example response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"version": "1.1.6"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Upgrade with the Installer
|
||||||
|
|
||||||
|
The installer uses the latest release by default:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://raw.githubusercontent.com/MengMengCode/CLICD/main/install.sh | sudo sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Install a specific version:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://raw.githubusercontent.com/MengMengCode/CLICD/main/install.sh | sudo CLICD_VERSION=v1.1.6 sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pre-upgrade Checklist
|
||||||
|
|
||||||
|
- Back up `/root/.clicd/` or the actual configuration directory.
|
||||||
|
- Make sure no critical tasks are currently running.
|
||||||
|
- If an image download or snapshot restore is running, wait for it to finish first.
|
||||||
|
- After upgrading, check `systemctl status clicd` and the version shown in the web panel.
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
---
|
||||||
|
layout: home
|
||||||
|
|
||||||
|
hero:
|
||||||
|
name: CLICD
|
||||||
|
text: Lightweight LXC/KVM Virtualization Panel
|
||||||
|
tagline: Web console, CLI, container orchestration, NAT/IPv4/IPv6 routing, snapshots, security alerts, sub-users, and API automation.
|
||||||
|
actions:
|
||||||
|
- theme: brand
|
||||||
|
text: Install
|
||||||
|
link: /en/guide/installation
|
||||||
|
- theme: alt
|
||||||
|
text: API Reference
|
||||||
|
link: /en/features/api
|
||||||
|
|
||||||
|
features:
|
||||||
|
- title: Built for Small Hosts
|
||||||
|
details: Manage LXC containers and KVM virtual machines on a single VPS or dedicated server.
|
||||||
|
- title: Web and CLI Together
|
||||||
|
details: Use the web panel for daily operations, or drop into the clicd CLI for maintenance tasks.
|
||||||
|
- title: Automation Friendly
|
||||||
|
details: /api/v1 exposes containers, images, snapshots, security, logs, sub-users, and API key management.
|
||||||
|
---
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# Deployment
|
||||||
|
|
||||||
|
CLICD can run directly on the host or behind a reverse proxy. In production, set up access control before exposing it to administrators.
|
||||||
|
|
||||||
|
## Service Exposure
|
||||||
|
|
||||||
|
The default web port is `8999`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://YOUR_SERVER_IP:8999
|
||||||
|
```
|
||||||
|
|
||||||
|
Recommendations:
|
||||||
|
|
||||||
|
- Allow only fixed administrator IPs.
|
||||||
|
- Use a reverse proxy with HTTPS.
|
||||||
|
- Do not expose the real login URL in public docs or screenshots.
|
||||||
|
|
||||||
|
## systemd
|
||||||
|
|
||||||
|
Common commands:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl status clicd
|
||||||
|
systemctl restart clicd
|
||||||
|
systemctl enable clicd
|
||||||
|
journalctl -u clicd -f
|
||||||
|
```
|
||||||
|
|
||||||
|
## Firewall
|
||||||
|
|
||||||
|
At minimum, confirm:
|
||||||
|
|
||||||
|
- The panel port is open only to trusted sources.
|
||||||
|
- NAT mapped ports are opened only as needed.
|
||||||
|
- The SSH management port does not conflict with container mappings.
|
||||||
|
- IPv6 firewall rules are planned together with IPv4 rules.
|
||||||
|
|
||||||
|
## Backups
|
||||||
|
|
||||||
|
Back up regularly:
|
||||||
|
|
||||||
|
- CLICD configuration directory.
|
||||||
|
- SQLite database.
|
||||||
|
- Container configuration.
|
||||||
|
- Snapshots or external data backups for important containers.
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# FAQ
|
||||||
|
|
||||||
|
## Which version does the installer install by default?
|
||||||
|
|
||||||
|
It installs the latest version from GitHub Releases. The script default is `CLICD_VERSION=latest`, which downloads the Linux AMD64 artifact from `releases/latest`.
|
||||||
|
|
||||||
|
## Can I pin a specific version?
|
||||||
|
|
||||||
|
Yes:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://raw.githubusercontent.com/MengMengCode/CLICD/main/install.sh | sudo CLICD_VERSION=v1.1.6 sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Can sub-users see every container?
|
||||||
|
|
||||||
|
No. Sub-users only see containers authorized by the administrator.
|
||||||
|
|
||||||
|
## Is an API key the same as the login password?
|
||||||
|
|
||||||
|
No. API keys are created on the API Integration page for programmatic access. The login password is used for the web panel.
|
||||||
|
|
||||||
|
## What happens after a container reaches its traffic limit?
|
||||||
|
|
||||||
|
The container is automatically shut down to avoid further overage. The administrator can adjust the limit or reset traffic usage.
|
||||||
|
|
||||||
|
## Why is IPv6 unreachable after assignment?
|
||||||
|
|
||||||
|
IPv6 reachability depends on the host and upstream network. Confirm that the host has a routable IPv6 prefix and that routing, firewall, neighbor discovery, or proxy configuration is correct.
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# Troubleshooting
|
||||||
|
|
||||||
|
## Service Not Reachable
|
||||||
|
|
||||||
|
Check service status:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl status clicd
|
||||||
|
journalctl -u clicd -n 100 --no-pager
|
||||||
|
```
|
||||||
|
|
||||||
|
Check port listening:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ss -lntp | grep 8999
|
||||||
|
```
|
||||||
|
|
||||||
|
If a reverse proxy is used, check proxy logs and upstream address settings as well.
|
||||||
|
|
||||||
|
## Image Download Failed
|
||||||
|
|
||||||
|
- Make sure the host can access image sources and GitHub Releases.
|
||||||
|
- Check disk space.
|
||||||
|
- Review the failure reason in the task queue.
|
||||||
|
- If a download is stuck, cancel it and start again.
|
||||||
|
|
||||||
|
## Container Cannot Access the Network
|
||||||
|
|
||||||
|
- Check host NAT and forwarding rules.
|
||||||
|
- Confirm that the container IP was assigned successfully.
|
||||||
|
- Check whether the firewall is blocking forwarded traffic.
|
||||||
|
- For IPv6, confirm that the upstream network routes the prefix to the host.
|
||||||
|
|
||||||
|
## WebSSH or WebVNC Connection Failed
|
||||||
|
|
||||||
|
- Confirm that the container or virtual machine is running.
|
||||||
|
- WebSSH requires SSH service inside the container.
|
||||||
|
- WebVNC requires the KVM console to be reachable.
|
||||||
|
- Tickets expire quickly. Create a new ticket after expiration.
|
||||||
|
|
||||||
|
## API Returns Unauthorized
|
||||||
|
|
||||||
|
- Confirm that the API key is not disabled.
|
||||||
|
- Use `X-API-Key` or `Authorization: Bearer`.
|
||||||
|
- Confirm that the key scope covers the target endpoint.
|
||||||
|
- Do not use the panel login password as an API key.
|
||||||
@@ -0,0 +1,674 @@
|
|||||||
|
# API 集成
|
||||||
|
|
||||||
|
CLICD 继续兼容旧版 `/api` 接口,已有对接无需修改。新接入推荐使用 `/api/v1` 接口,下面的清单均为 v1;容器列表推荐 `GET /api/v1/containers`。
|
||||||
|
|
||||||
|
## 认证
|
||||||
|
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
## 响应结构
|
||||||
|
|
||||||
|
所有接口保持统一响应包裹:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "OK",
|
||||||
|
"data": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
对接时建议只读取业务所需字段。新增能力会优先追加可选字段,不会要求已有插件改掉现有字段名。
|
||||||
|
|
||||||
|
## 创建与重装
|
||||||
|
|
||||||
|
创建容器、批量创建、重装和批量重装已支持 NAT、公网 IPv4、IPv6 混合网络,以及 Linux SSH 登录方式配置。公网 IPv4/IPv6 地址池可通过 `GET /api/v1/routing` 查看,并可通过 `PUT /api/v1/routing` 更新。
|
||||||
|
|
||||||
|
创建容器示例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "demo-lxc-01",
|
||||||
|
"virtualization": "lxc",
|
||||||
|
"template_id": "debian-bookworm",
|
||||||
|
"vcpu": 1,
|
||||||
|
"ram_mb": 512,
|
||||||
|
"disk_gb": 10,
|
||||||
|
"assign_nat": true,
|
||||||
|
"port_mapping_count": 2,
|
||||||
|
"assign_ipv4": false,
|
||||||
|
"ipv4_count": 1,
|
||||||
|
"public_ipv4s": [],
|
||||||
|
"assign_ipv6": true,
|
||||||
|
"ipv6_count": 1,
|
||||||
|
"ipv6_addresses": [],
|
||||||
|
"ssh_auth_mode": "auto_password",
|
||||||
|
"ssh_password": "",
|
||||||
|
"ssh_public_key": "",
|
||||||
|
"expires_at": ""
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
字段说明:
|
||||||
|
|
||||||
|
| 字段 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| `assign_nat` | 是否分配 NAT 端口映射;不传时保持默认 NAT 行为。 |
|
||||||
|
| `assign_ipv4` | 是否分配公网 IPv4。 |
|
||||||
|
| `ipv4_count` | 自动分配公网 IPv4 数量。 |
|
||||||
|
| `public_ipv4s` | 指定公网 IPv4 地址列表。 |
|
||||||
|
| `assign_ipv6` | 是否分配 IPv6。 |
|
||||||
|
| `ipv6_count` | 自动分配 IPv6 数量。 |
|
||||||
|
| `ipv6_addresses` | 指定 IPv6 地址列表。 |
|
||||||
|
| `ssh_auth_mode` | Linux 创建支持 `auto_password`、`password`、`key`;重装额外支持 `keep`。 |
|
||||||
|
| `ssh_password` | `password` 模式下的自定义密码;8-64 位,至少包含字母和数字,不能包含空白字符。 |
|
||||||
|
| `ssh_public_key` | `key` 模式下的一行 SSH 公钥。 |
|
||||||
|
|
||||||
|
重装示例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"template_id": "debian-bookworm",
|
||||||
|
"ssh_auth_mode": "keep",
|
||||||
|
"ssh_password": "",
|
||||||
|
"ssh_public_key": ""
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`keep` 仅用于重装,表示沿用当前 SSH 密码。Windows KVM 镜像会忽略 Linux SSH 公钥相关字段。
|
||||||
|
|
||||||
|
## 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()
|
||||||
|
print(resp.json())
|
||||||
|
```
|
||||||
|
|
||||||
|
创建端口映射:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import requests
|
||||||
|
|
||||||
|
BASE_URL = "https://panel.example.com"
|
||||||
|
API_KEY = "YOUR_API_KEY"
|
||||||
|
CONTAINER_ID = "example-vm"
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"protocol": "tcp",
|
||||||
|
"host_port": 18080,
|
||||||
|
"container_port": 80,
|
||||||
|
"description": "web",
|
||||||
|
}
|
||||||
|
|
||||||
|
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())
|
||||||
|
```
|
||||||
|
|
||||||
|
## 接口清单
|
||||||
|
|
||||||
|
### 总览
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| GET | `/api/v1/dashboard` | 控制面板统计 |
|
||||||
|
| GET | `/api/v1/host-info` | 主机资源 |
|
||||||
|
| GET | `/api/v1/routing` | NAT/IPv4/IPv6 路由 |
|
||||||
|
| PUT | `/api/v1/routing` | 更新公网 IPv4/IPv6 池 |
|
||||||
|
| POST | `/api/v1/routing/ipv4-scan` | 扫描公网 IPv4 段 |
|
||||||
|
| GET | `/api/v1/ipv6/status` | IPv6 状态 |
|
||||||
|
| GET | `/api/v1/tasks` | 任务队列 |
|
||||||
|
| DELETE | `/api/v1/tasks/{task_id}` | 删除任务 |
|
||||||
|
|
||||||
|
### 容器
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| GET | `/api/v1/containers` | 容器列表 |
|
||||||
|
| POST | `/api/v1/containers/list` | 容器列表兼容 POST 写法 |
|
||||||
|
| POST | `/api/v1/containers` | 创建容器 |
|
||||||
|
| GET | `/api/v1/containers/{id|uuid|name}` | 容器详情 |
|
||||||
|
| 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/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` | 调整到期时间 |
|
||||||
|
| POST | `/api/v1/containers/{id}/reset-password` | 重置 SSH 密码 |
|
||||||
|
| POST | `/api/v1/containers/{id}/ipv6` | 分配 IPv6 |
|
||||||
|
|
||||||
|
### 端口与快照
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 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}` | 删除端口映射 |
|
||||||
|
| GET | `/api/v1/snapshots` | 快照总览 |
|
||||||
|
| 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` | 恢复快照 |
|
||||||
|
| POST | `/api/v1/containers/{id}/snapshots/schedule` | 计划快照 |
|
||||||
|
| PUT | `/api/v1/containers/{id}/snapshots/quota` | 快照配额 |
|
||||||
|
|
||||||
|
### 平台管理
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 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` | 启用/禁用镜像 |
|
||||||
|
| 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` | 更新安全设置 |
|
||||||
|
| GET | `/api/v1/swap` | Swap 信息 |
|
||||||
|
| POST | `/api/v1/swap` | 调整 Swap |
|
||||||
|
| POST | `/api/v1/batch-create` | 批量创建容器 |
|
||||||
|
| POST | `/api/v1/batch-action` | 批量开关机/删除/重装 |
|
||||||
|
| POST | `/api/v1/ssh-ticket` | 创建 WebSSH 票据 |
|
||||||
|
| POST | `/api/v1/vnc-ticket` | 创建 WebVNC 票据 |
|
||||||
|
|
||||||
|
### 账号与日志
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| POST | `/api/v1/sub-user/create` | 创建子用户链接 |
|
||||||
|
| 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` | 子用户登录日志 |
|
||||||
|
| GET | `/api/v1/audit-logs` | 操作日志 |
|
||||||
|
| GET | `/api/v1/login-logs` | 登录日志 |
|
||||||
|
| GET | `/api/v1/api-keys` | API Key 列表 |
|
||||||
|
| POST | `/api/v1/api-keys` | 创建 API Key |
|
||||||
|
| PATCH | `/api/v1/api-keys/{id}` | 更新 API Key |
|
||||||
|
| DELETE | `/api/v1/api-keys/{id}` | 删除 API Key |
|
||||||
|
|
||||||
|
## 返回样例
|
||||||
|
|
||||||
|
以下样例按接口路径分组。真实环境中的资源数值、任务 ID、容器 ID、时间、IP 和密钥会不同,示例中的密码、票据和 API Key 均已脱敏。
|
||||||
|
|
||||||
|
### 总览
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"GET /api/v1/dashboard": {
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"running": 31,
|
||||||
|
"stopped": 0,
|
||||||
|
"total_containers": 31
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"GET /api/v1/host-info": {
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"cpu": { "cores": 8, "usage_pct": 1.16 },
|
||||||
|
"ram": { "total_mb": 31825, "used_mb": 1275, "free_mb": 30550 },
|
||||||
|
"disk": { "total_gb": 1750.49, "used_gb": 123.98, "free_gb": 1626.51 },
|
||||||
|
"network": {
|
||||||
|
"public_ipv4": "203.0.113.10",
|
||||||
|
"public_ipv4_interface": "eth0",
|
||||||
|
"public_ipv6": "2001:db8:100::2",
|
||||||
|
"public_ipv6_interface": "eth0"
|
||||||
|
},
|
||||||
|
"load": { "load1": 0.01, "load5": 0.03, "load15": 0.01 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"GET /api/v1/routing": {
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"nat4": { "used": 62, "remaining": "45474", "total": "45536" },
|
||||||
|
"ipv4": { "used": 1, "remaining": "3", "total": "4" },
|
||||||
|
"ipv6": { "used": 31, "remaining": "large", "total": "large" },
|
||||||
|
"public_ipv4_addresses": [
|
||||||
|
{ "address": "203.0.113.10", "interface": "eth0", "prefix_len": 32, "gateway": "203.0.113.1" }
|
||||||
|
],
|
||||||
|
"ipv4_assignments": [
|
||||||
|
{ "container_id": 5, "container_name": "example-vm", "address": "203.0.113.10", "interface": "eth0", "prefix_len": 32, "gateway": "203.0.113.1" }
|
||||||
|
],
|
||||||
|
"nat4_mappings": [
|
||||||
|
{ "container_id": 5, "container_name": "example-vm", "status": "running", "ip": "10.0.0.10", "host_port": 22004, "container_port": 22, "protocol": "tcp" }
|
||||||
|
],
|
||||||
|
"ipv6_assignments": [
|
||||||
|
{ "container_id": 5, "container_name": "example-vm", "address": "2001:db8:100::1005", "prefix_len": 64, "interface": "eth0" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"PUT /api/v1/routing": {
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"ipv4": { "used": 1, "remaining": "3", "total": "4" },
|
||||||
|
"public_ipv4_addresses": [
|
||||||
|
{ "address": "203.0.113.10", "interface": "eth0", "prefix_len": 32, "gateway": "203.0.113.1" }
|
||||||
|
],
|
||||||
|
"ipv6_prefixes": [
|
||||||
|
{ "interface": "eth0", "address": "2001:db8:100::2", "prefix": "2001:db8:100::/64", "prefix_len": 64, "gateway": "2001:db8:100::1" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"POST /api/v1/routing/ipv4-scan": {
|
||||||
|
"success": true,
|
||||||
|
"data": [
|
||||||
|
{ "address": "203.0.113.10", "interface": "eth0", "prefix_len": 32, "gateway": "203.0.113.1", "status": "available", "usable": true, "reason": "" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"GET /api/v1/ipv6/status": {
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"available": true,
|
||||||
|
"reachable": true,
|
||||||
|
"reason": "usable public IPv6 prefix detected",
|
||||||
|
"prefixes": [
|
||||||
|
{ "interface": "eth0", "address": "2001:db8:100::2", "prefix": "2001:db8:100::/64", "prefix_len": 64, "gateway": "2001:db8:100::1" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"GET /api/v1/tasks": {
|
||||||
|
"success": true,
|
||||||
|
"data": []
|
||||||
|
},
|
||||||
|
"DELETE /api/v1/tasks/{task_id}": {
|
||||||
|
"success": true,
|
||||||
|
"message": "Task deleted"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 容器
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"GET /api/v1/containers": {
|
||||||
|
"success": true,
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"id": 5,
|
||||||
|
"uuid": "00000000-0000-4000-8000-000000000005",
|
||||||
|
"name": "example-vm",
|
||||||
|
"virtualization": "lxc",
|
||||||
|
"template": "debian-bullseye",
|
||||||
|
"vcpu": 1,
|
||||||
|
"ram_mb": 512,
|
||||||
|
"disk_gb": 10,
|
||||||
|
"status": "running",
|
||||||
|
"ip": "10.0.0.10",
|
||||||
|
"ipv6": "2001:db8:100::1005",
|
||||||
|
"ssh_port": 22004,
|
||||||
|
"ssh_password": "***",
|
||||||
|
"port_mappings": [
|
||||||
|
{ "container_port": 22, "host_port": 22004, "protocol": "tcp", "description": "SSH" },
|
||||||
|
{ "container_port": 20000, "host_port": 20000, "protocol": "tcp", "description": "Port-20000" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"POST /api/v1/containers/list": {
|
||||||
|
"success": true,
|
||||||
|
"data": [
|
||||||
|
{ "id": 5, "uuid": "00000000-0000-4000-8000-000000000005", "name": "example-vm", "status": "running", "ip": "10.0.0.10" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"POST /api/v1/containers": {
|
||||||
|
"success": true,
|
||||||
|
"message": "Container created successfully"
|
||||||
|
},
|
||||||
|
"GET /api/v1/containers/{id|uuid|name}": {
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"id": 5,
|
||||||
|
"uuid": "00000000-0000-4000-8000-000000000005",
|
||||||
|
"name": "example-vm",
|
||||||
|
"status": "running",
|
||||||
|
"ip": "10.0.0.10",
|
||||||
|
"ipv6": "2001:db8:100::1005",
|
||||||
|
"ssh_port": 22004,
|
||||||
|
"ssh_password": "***",
|
||||||
|
"policy_blocked": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"POST /api/v1/containers/{id}/start": {
|
||||||
|
"success": true,
|
||||||
|
"message": "Task queued",
|
||||||
|
"data": { "task_id": "task-10", "container_name": "example-vm", "status": "pending", "action": "start" }
|
||||||
|
},
|
||||||
|
"POST /api/v1/containers/{id}/stop": {
|
||||||
|
"success": true,
|
||||||
|
"message": "Task queued",
|
||||||
|
"data": { "task_id": "task-10", "container_name": "example-vm", "status": "pending", "action": "stop" }
|
||||||
|
},
|
||||||
|
"POST /api/v1/containers/{id}/restart": {
|
||||||
|
"success": true,
|
||||||
|
"message": "Task queued",
|
||||||
|
"data": { "task_id": "task-10", "container_name": "example-vm", "status": "pending", "action": "restart" }
|
||||||
|
},
|
||||||
|
"POST /api/v1/containers/{id}/reinstall": {
|
||||||
|
"success": true,
|
||||||
|
"message": "Task queued",
|
||||||
|
"data": { "task_id": "task-10", "container_name": "example-vm", "status": "pending", "action": "reinstall" }
|
||||||
|
},
|
||||||
|
"DELETE /api/v1/containers/{id}/delete": {
|
||||||
|
"success": true,
|
||||||
|
"message": "Task queued",
|
||||||
|
"data": { "task_id": "task-10", "container_name": "example-vm", "status": "pending", "action": "delete" }
|
||||||
|
},
|
||||||
|
"GET /api/v1/containers/{id}/usage": {
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"cpu_usage_pct": 0,
|
||||||
|
"cpu_usage_usec": 3908852,
|
||||||
|
"memory_usage_bytes": 29331456,
|
||||||
|
"disk_usage_bytes": 515100672,
|
||||||
|
"network_rx_bytes": 131232,
|
||||||
|
"network_tx_bytes": 16828,
|
||||||
|
"load1": 0.1,
|
||||||
|
"load5": 0.06,
|
||||||
|
"load15": 0.01
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"GET /api/v1/containers/{id}/traffic": {
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"mode": "total",
|
||||||
|
"limit_gb": 0,
|
||||||
|
"in_limit_gb": 0,
|
||||||
|
"out_limit_gb": 0,
|
||||||
|
"total_used_bytes": 142082,
|
||||||
|
"rx_used_bytes": 127212,
|
||||||
|
"tx_used_bytes": 14870,
|
||||||
|
"used_pct": 0,
|
||||||
|
"reset_date": "2026-06"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"POST /api/v1/containers/{id}/traffic-reset": {
|
||||||
|
"success": true,
|
||||||
|
"message": "Traffic reset"
|
||||||
|
},
|
||||||
|
"PUT /api/v1/containers/{id}/traffic-limit": {
|
||||||
|
"success": true,
|
||||||
|
"message": "Traffic limit updated"
|
||||||
|
},
|
||||||
|
"PUT /api/v1/containers/{id}/resource-limit": {
|
||||||
|
"success": true,
|
||||||
|
"message": "Resource limits updated"
|
||||||
|
},
|
||||||
|
"PUT /api/v1/containers/{id}/expiry": {
|
||||||
|
"success": true,
|
||||||
|
"message": "Expiry updated"
|
||||||
|
},
|
||||||
|
"POST /api/v1/containers/{id}/reset-password": {
|
||||||
|
"success": true,
|
||||||
|
"message": "SSH password reset successfully",
|
||||||
|
"data": { "password": "***" }
|
||||||
|
},
|
||||||
|
"POST /api/v1/containers/{id}/ipv6": {
|
||||||
|
"success": true,
|
||||||
|
"message": "IPv6 assigned",
|
||||||
|
"data": { "id": 5, "name": "example-vm", "ipv6": "2001:db8:100::1005" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 端口与快照
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"GET /api/v1/containers/{id}/random-port": {
|
||||||
|
"success": true,
|
||||||
|
"data": { "port": 61320 }
|
||||||
|
},
|
||||||
|
"POST /api/v1/containers/{id}/port-mappings": {
|
||||||
|
"success": true,
|
||||||
|
"data": [
|
||||||
|
{ "container_port": 22, "host_port": 22004, "protocol": "tcp", "description": "SSH" },
|
||||||
|
{ "container_port": 8080, "host_port": 61320, "protocol": "tcp", "description": "HTTP" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"PUT /api/v1/containers/{id}/port-mappings/{index}": {
|
||||||
|
"success": true,
|
||||||
|
"data": [
|
||||||
|
{ "container_port": 8081, "host_port": 61320, "protocol": "tcp", "description": "HTTP" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"DELETE /api/v1/containers/{id}/port-mappings/{index}": {
|
||||||
|
"success": true,
|
||||||
|
"data": []
|
||||||
|
},
|
||||||
|
"GET /api/v1/snapshots": {
|
||||||
|
"success": true,
|
||||||
|
"data": null
|
||||||
|
},
|
||||||
|
"GET /api/v1/containers/{id}/snapshots": {
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"quota": 1,
|
||||||
|
"schedule": { "enabled": false, "interval_hours": 0, "last_run": "", "next_run": "", "time": "", "created_by": "" },
|
||||||
|
"snapshots": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"POST /api/v1/containers/{id}/snapshots": {
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"id": "snap-20260608-001",
|
||||||
|
"container_id": 5,
|
||||||
|
"container_name": "example-vm",
|
||||||
|
"created_at": "2026-06-08 16:00:00",
|
||||||
|
"created_by": "api:Automation",
|
||||||
|
"scheduled": false,
|
||||||
|
"size_bytes": 10485760
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"DELETE /api/v1/containers/{id}/snapshots/{snapshot_id}": {
|
||||||
|
"success": true,
|
||||||
|
"message": "Snapshot deleted"
|
||||||
|
},
|
||||||
|
"POST /api/v1/containers/{id}/snapshots/{snapshot_id}/restore": {
|
||||||
|
"success": true,
|
||||||
|
"message": "Snapshot restored"
|
||||||
|
},
|
||||||
|
"POST /api/v1/containers/{id}/snapshots/schedule": {
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"container": { "id": 5, "name": "example-vm", "snapshot_schedule_enabled": true, "snapshot_schedule_interval_hours": 24, "snapshot_schedule_time": "03:00" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"PUT /api/v1/containers/{id}/snapshots/quota": {
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"quota": 2,
|
||||||
|
"container": { "id": 5, "name": "example-vm", "snapshot_limit": 2 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 平台管理
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"GET /api/v1/templates": {
|
||||||
|
"success": true,
|
||||||
|
"data": [
|
||||||
|
{ "id": "ubuntu-noble", "name": "Ubuntu 24.04", "distro": "ubuntu", "release": "noble", "arch": "amd64", "description": "Ubuntu 24.04 LTS" },
|
||||||
|
{ "id": "debian-bookworm", "name": "Debian 12", "distro": "debian", "release": "bookworm", "arch": "amd64", "description": "Debian 12 (Bookworm)" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"GET /api/v1/images": {
|
||||||
|
"success": true,
|
||||||
|
"data": [
|
||||||
|
{ "id": "ubuntu-noble", "name": "Ubuntu 24.04", "type": "lxc", "downloaded": true, "enabled": true, "downloading": false, "progress": 0, "size_bytes": 135005452 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"POST /api/v1/images/download": {
|
||||||
|
"success": true,
|
||||||
|
"message": "Already downloaded"
|
||||||
|
},
|
||||||
|
"POST /api/v1/images/cancel": {
|
||||||
|
"success": true,
|
||||||
|
"message": "Cancel requested"
|
||||||
|
},
|
||||||
|
"DELETE /api/v1/images/delete": {
|
||||||
|
"success": true,
|
||||||
|
"message": "Deleted"
|
||||||
|
},
|
||||||
|
"PUT /api/v1/images/toggle": {
|
||||||
|
"success": true,
|
||||||
|
"message": "OK"
|
||||||
|
},
|
||||||
|
"GET /api/v1/security/alerts": {
|
||||||
|
"success": true,
|
||||||
|
"data": []
|
||||||
|
},
|
||||||
|
"POST /api/v1/security/check": {
|
||||||
|
"success": true,
|
||||||
|
"message": "Security check completed"
|
||||||
|
},
|
||||||
|
"GET /api/v1/security/logs?container={name}": {
|
||||||
|
"success": true,
|
||||||
|
"data": []
|
||||||
|
},
|
||||||
|
"GET /api/v1/security/summary": {
|
||||||
|
"success": true,
|
||||||
|
"data": { "critical": 0, "high": 0, "medium": 0, "low": 0, "total_alerts": 0 }
|
||||||
|
},
|
||||||
|
"GET /api/v1/security/settings": {
|
||||||
|
"success": true,
|
||||||
|
"data": { "auto_shutdown": false }
|
||||||
|
},
|
||||||
|
"PUT /api/v1/security/settings": {
|
||||||
|
"success": true,
|
||||||
|
"data": { "auto_shutdown": false }
|
||||||
|
},
|
||||||
|
"GET /api/v1/swap": {
|
||||||
|
"success": true,
|
||||||
|
"data": { "total_mb": 16383, "used_mb": 0, "free_mb": 16383, "enabled": true, "swap_file": "/swapfile" }
|
||||||
|
},
|
||||||
|
"POST /api/v1/swap": {
|
||||||
|
"success": true,
|
||||||
|
"message": "SWAP 已调整为 16384 MB",
|
||||||
|
"data": { "total_mb": 16383, "used_mb": 0, "free_mb": 16383, "enabled": true, "swap_file": "/swapfile" }
|
||||||
|
},
|
||||||
|
"POST /api/v1/batch-create": {
|
||||||
|
"success": true,
|
||||||
|
"data": ["task-12"]
|
||||||
|
},
|
||||||
|
"POST /api/v1/batch-action": {
|
||||||
|
"success": true,
|
||||||
|
"data": ["task-13"]
|
||||||
|
},
|
||||||
|
"POST /api/v1/ssh-ticket": {
|
||||||
|
"success": true,
|
||||||
|
"data": { "ticket": "***60秒有效票据***" }
|
||||||
|
},
|
||||||
|
"POST /api/v1/vnc-ticket": {
|
||||||
|
"success": true,
|
||||||
|
"data": { "ticket": "***60秒有效票据***" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 账号与日志
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"POST /api/v1/sub-user/create": {
|
||||||
|
"success": true,
|
||||||
|
"message": "Sub-user created",
|
||||||
|
"data": {
|
||||||
|
"id": "sub-xxxxxxxx",
|
||||||
|
"username": "user-xxxxxxxx",
|
||||||
|
"password": "***",
|
||||||
|
"container_names": ["example-vm"],
|
||||||
|
"access_code": "********",
|
||||||
|
"created_at": "2026-06-08 16:00:00"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"GET /api/v1/sub-users": {
|
||||||
|
"success": true,
|
||||||
|
"data": []
|
||||||
|
},
|
||||||
|
"POST /api/v1/sub-users/{id}/rotate-password": {
|
||||||
|
"success": true,
|
||||||
|
"data": { "username": "user-xxxxxxxx", "password": "***", "access_code": "********" }
|
||||||
|
},
|
||||||
|
"GET /api/v1/sub-users/{id}/audit-logs": {
|
||||||
|
"success": true,
|
||||||
|
"data": []
|
||||||
|
},
|
||||||
|
"GET /api/v1/sub-users/{id}/login-logs": {
|
||||||
|
"success": true,
|
||||||
|
"data": []
|
||||||
|
},
|
||||||
|
"GET /api/v1/audit-logs": {
|
||||||
|
"success": true,
|
||||||
|
"data": [
|
||||||
|
{ "time": "2026-06-08 15:44:40", "action": "apikey.create", "target": "Test", "detail": "scopes=*", "user": "admin", "success": true }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"GET /api/v1/login-logs": {
|
||||||
|
"success": true,
|
||||||
|
"data": [
|
||||||
|
{ "time": "2026-06-08 08:24:00 UTC", "username": "admin", "ip": "198.51.100.23", "user_agent": "Mozilla/5.0 ...", "success": true }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"GET /api/v1/api-keys": {
|
||||||
|
"success": true,
|
||||||
|
"data": [
|
||||||
|
{ "id": "c271023f", "name": "Test", "prefix": "clicd_sk_dd9d...", "ip_whitelist": "", "created_at": "2026-06-08 15:44:40", "last_used": "2026-06-08 15:46:10", "scopes": ["*"], "last_used_ip": "198.51.100.23" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"POST /api/v1/api-keys": {
|
||||||
|
"success": true,
|
||||||
|
"message": "API key created. Save this key now - it won't be shown again.",
|
||||||
|
"data": { "id": "a1b2c3d4", "name": "Automation", "key": "clicd_sk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", "prefix": "clicd_sk_xxxx...", "scopes": ["dashboard:read", "container:read"] }
|
||||||
|
},
|
||||||
|
"PATCH /api/v1/api-keys/{id}": {
|
||||||
|
"success": true,
|
||||||
|
"data": { "id": "a1b2c3d4", "name": "Automation", "prefix": "clicd_sk_xxxx...", "scopes": ["dashboard:read", "container:read"], "disabled": false }
|
||||||
|
},
|
||||||
|
"DELETE /api/v1/api-keys/{id}": {
|
||||||
|
"success": true,
|
||||||
|
"message": "API key deleted"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# 容器管理
|
||||||
|
|
||||||
|
容器管理是 CLICD 的核心模块,覆盖创建、生命周期控制、资源限制、网络映射、流量统计、密码重置和控制台访问。
|
||||||
|
|
||||||
|
## 容器列表
|
||||||
|
|
||||||
|
列表页用于扫描所有容器状态。管理员可以查看全部容器,子用户只能看到授权范围内的容器。
|
||||||
|
|
||||||
|
常见字段包括:
|
||||||
|
|
||||||
|
- ID、UUID、名称。
|
||||||
|
- 虚拟化类型。
|
||||||
|
- 运行状态。
|
||||||
|
- IP、IPv6。
|
||||||
|
- CPU、内存、磁盘限制。
|
||||||
|
- 流量使用量和流量上限。
|
||||||
|
- 到期时间。
|
||||||
|
|
||||||
|
## 创建容器
|
||||||
|
|
||||||
|
创建时需要选择模板,并设置资源配额。批量创建可以通过面板或 API 完成,适合一次性发放多个容器。
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/v1/containers
|
||||||
|
POST /api/v1/batch-create
|
||||||
|
```
|
||||||
|
|
||||||
|
Linux 容器和 Linux KVM 虚拟机创建时支持配置 SSH 登录方式:
|
||||||
|
|
||||||
|
- `auto_password`:自动生成 root SSH 密码。
|
||||||
|
- `password`:使用自定义 `ssh_password`。
|
||||||
|
- `key`:写入一行 `ssh_public_key`,仍会保留可用于 WebSSH 的密码。
|
||||||
|
|
||||||
|
网络分配可以按需组合 NAT、公网 IPv4 和 IPv6。API 字段保持为 `assign_nat`、`assign_ipv4`、`public_ipv4s`、`assign_ipv6`、`ipv6_addresses` 等可选字段,未传时沿用默认行为。
|
||||||
|
|
||||||
|
## 生命周期操作
|
||||||
|
|
||||||
|
```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` 查看执行状态。
|
||||||
|
|
||||||
|
重装 Linux 系统时可传 `ssh_auth_mode`、`ssh_password`、`ssh_public_key`。`ssh_auth_mode=keep` 表示沿用当前 SSH 密码;不传这些字段时保持旧行为。
|
||||||
|
|
||||||
|
## 资源与流量
|
||||||
|
|
||||||
|
容器详情页支持查看资源用量,调整流量限制、资源限制和到期时间。
|
||||||
|
|
||||||
|
```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,61 @@
|
|||||||
|
# 网络与路由
|
||||||
|
|
||||||
|
CLICD 提供 NAT4 端口映射、随机可用端口、公网 IPv4 分配、IPv6 状态检查和 IPv6 分配能力。创建容器时可以只分配 NAT、只分配公网 IPv4、只分配 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 或上游没有正确路由,面板中分配出的地址也无法从公网访问。
|
||||||
|
|
||||||
|
## 公网 IPv4
|
||||||
|
|
||||||
|
公网 IPv4 分配会从主机检测到的可用公网 IPv4 中选择地址,或使用 API 指定的 `public_ipv4s`。创建容器时可使用:
|
||||||
|
|
||||||
|
| 字段 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| `assign_nat` | 是否启用 NAT 端口映射。 |
|
||||||
|
| `assign_ipv4` | 是否分配公网 IPv4。 |
|
||||||
|
| `ipv4_count` | 自动分配公网 IPv4 数量。 |
|
||||||
|
| `public_ipv4s` | 指定公网 IPv4 地址列表。 |
|
||||||
|
| `assign_ipv6` | 是否分配 IPv6。 |
|
||||||
|
| `ipv6_count` | 自动分配 IPv6 数量。 |
|
||||||
|
| `ipv6_addresses` | 指定 IPv6 地址列表。 |
|
||||||
|
|
||||||
|
公网地址池相关接口:
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/v1/routing
|
||||||
|
PUT /api/v1/routing
|
||||||
|
POST /api/v1/routing/ipv4-scan
|
||||||
|
```
|
||||||
|
|
||||||
|
## 路由状态
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/v1/routing
|
||||||
|
```
|
||||||
|
|
||||||
|
该接口用于查看 NAT、IPv6、端口容量等运行时状态。
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# 安全告警
|
||||||
|
|
||||||
|
CLICD 内置基于连接行为的轻量安全告警能力。它不保存完整正常连接日志,而是关注异常行为和高风险模式。
|
||||||
|
|
||||||
|
## 覆盖场景
|
||||||
|
|
||||||
|
- 端口扫描。
|
||||||
|
- 横向扫描。
|
||||||
|
- 爆破倾向。
|
||||||
|
- SMTP 滥用。
|
||||||
|
- UDP 反射风险。
|
||||||
|
- 挖矿、代理、VPN、Tor 等可疑端口。
|
||||||
|
|
||||||
|
## 接口
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/v1/security/alerts
|
||||||
|
POST /api/v1/security/check
|
||||||
|
GET /api/v1/security/logs?container={name}
|
||||||
|
GET /api/v1/security/summary
|
||||||
|
GET /api/v1/security/settings
|
||||||
|
PUT /api/v1/security/settings
|
||||||
|
```
|
||||||
|
|
||||||
|
## 自动关机
|
||||||
|
|
||||||
|
安全设置中可配置告警后的自动关机策略。开启前建议先观察一段时间,确认规则不会影响正常业务。
|
||||||
|
|
||||||
|
## 日志建议
|
||||||
|
|
||||||
|
安全告警适合做风险提示,不应替代专业防火墙、入侵检测或集中日志系统。对公网暴露服务时,仍建议结合安全组、防火墙、Fail2ban 等工具。
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# 快照管理
|
||||||
|
|
||||||
|
快照用于保存容器当前状态,方便在升级、变更配置或交付前回滚。
|
||||||
|
|
||||||
|
## 全局总览
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/v1/snapshots
|
||||||
|
```
|
||||||
|
|
||||||
|
用于查看所有容器的快照概览。
|
||||||
|
|
||||||
|
## 容器快照
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/v1/containers/{id}/snapshots
|
||||||
|
POST /api/v1/containers/{id}/snapshots
|
||||||
|
DELETE /api/v1/containers/{id}/snapshots/{snapshot_id}
|
||||||
|
POST /api/v1/containers/{id}/snapshots/{snapshot_id}/restore
|
||||||
|
```
|
||||||
|
|
||||||
|
恢复快照会改变容器状态,生产环境建议先确认当前业务是否可以中断。
|
||||||
|
|
||||||
|
## 计划快照与配额
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/v1/containers/{id}/snapshots/schedule
|
||||||
|
PUT /api/v1/containers/{id}/snapshots/quota
|
||||||
|
```
|
||||||
|
|
||||||
|
计划快照适合长期运行的容器。配额用于避免快照无限增长占满宿主机磁盘。
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# 子用户
|
||||||
|
|
||||||
|
子用户用于把指定容器授权给其他用户管理。它适合临时交付、拼车分配、教学实验或多人共用宿主机的场景。
|
||||||
|
|
||||||
|
## 创建访问链接
|
||||||
|
|
||||||
|
管理员选择容器后创建子用户链接:
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/v1/sub-user/create
|
||||||
|
```
|
||||||
|
|
||||||
|
返回内容中可能包含用户名、初始密码、访问码或访问链接。对外展示时必须脱敏,真实值只应发送给对应用户。
|
||||||
|
|
||||||
|
## 管理子用户
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/v1/sub-users
|
||||||
|
POST /api/v1/sub-users/{id}/rotate-password
|
||||||
|
GET /api/v1/sub-users/{id}/audit-logs
|
||||||
|
GET /api/v1/sub-users/{id}/login-logs
|
||||||
|
```
|
||||||
|
|
||||||
|
轮换密码会让旧凭证失效。审计日志和登录日志可用于排查误操作或异常访问。
|
||||||
|
|
||||||
|
## 权限范围
|
||||||
|
|
||||||
|
子用户只能管理被授权的容器。涉及全局配置、镜像管理、安全策略、API Key 等管理员功能不会开放给子用户。
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# 配置说明
|
||||||
|
|
||||||
|
CLICD 安装后会以 systemd 服务运行,运行时配置和数据库保存在宿主机本地。实际路径可能随安装脚本参数变化,默认安装建议以 `/root/.clicd/` 为主要检查位置。
|
||||||
|
|
||||||
|
## 常见配置项
|
||||||
|
|
||||||
|
| 配置 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| Web 端口 | 默认 `8999`,服务启动时监听 `0.0.0.0:8999`。 |
|
||||||
|
| 管理员账号 | 用于登录 Web 面板和管理 API Key。 |
|
||||||
|
| 数据库 | SQLite,用于保存容器元数据、子用户、审计日志、API Key 等。 |
|
||||||
|
| NAT 端口范围 | 用于随机端口和端口映射分配。 |
|
||||||
|
| IPv6 地址段 | 宿主机有可路由 IPv6 时可配置分配策略。 |
|
||||||
|
| 安全告警 | 可配置自动关机等策略。 |
|
||||||
|
|
||||||
|
## 服务命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl status clicd
|
||||||
|
systemctl restart clicd
|
||||||
|
journalctl -u clicd -n 100 --no-pager
|
||||||
|
```
|
||||||
|
|
||||||
|
## 安全建议
|
||||||
|
|
||||||
|
- 不要把 Web 面板直接暴露给不可信来源。
|
||||||
|
- 使用复杂管理员密码,并定期轮换。
|
||||||
|
- API Key 按用途拆分权限,避免长期使用全权限密钥。
|
||||||
|
- WebSSH、WebVNC 票据是短期凭证,不应写入日志或外发。
|
||||||
|
- 对外文档、截图和工单里不要粘贴真实 IP、密码、API Key 或票据。
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# 安装
|
||||||
|
|
||||||
|
CLICD 提供一键安装脚本。脚本默认安装 GitHub Releases 的最新版本,也可以通过环境变量指定固定版本。
|
||||||
|
|
||||||
|
## 环境要求
|
||||||
|
|
||||||
|
- Linux x86_64 宿主机。
|
||||||
|
- root 权限。
|
||||||
|
- systemd。
|
||||||
|
- 网络可访问 GitHub Release 下载地址。
|
||||||
|
- 如果要使用 LXC,需要宿主机支持 LXC 运行环境。
|
||||||
|
- 如果要使用 KVM,需要宿主机开启虚拟化并安装 libvirt/QEMU。
|
||||||
|
|
||||||
|
## 安装最新版本
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://raw.githubusercontent.com/MengMengCode/CLICD/main/install.sh | sudo sh
|
||||||
|
```
|
||||||
|
|
||||||
|
脚本当前默认使用 `CLICD_VERSION=latest`,也就是下载 `releases/latest` 对应的 `clicd-linux-amd64.tar.gz`。
|
||||||
|
|
||||||
|
## 安装指定版本
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://raw.githubusercontent.com/MengMengCode/CLICD/main/install.sh | sudo CLICD_VERSION=v1.1.6 sh
|
||||||
|
```
|
||||||
|
|
||||||
|
把 `v1.1.6` 替换成需要安装的 Release 标签即可。
|
||||||
|
|
||||||
|
## 访问面板
|
||||||
|
|
||||||
|
安装完成后,浏览器访问:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://YOUR_SERVER_IP:8999
|
||||||
|
```
|
||||||
|
|
||||||
|
首次登录请使用安装脚本输出的管理员账号信息。生产环境建议在防火墙或反向代理层限制访问来源,并尽快修改默认账号和密码。
|
||||||
|
|
||||||
|
## 卸载
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://raw.githubusercontent.com/MengMengCode/CLICD/main/install.sh | sudo sh -s -- uninstall
|
||||||
|
```
|
||||||
|
|
||||||
|
卸载前请确认是否需要保留容器、镜像缓存、数据库和配置文件。
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# 项目介绍
|
||||||
|
|
||||||
|
CLICD 是一个面向 LXC/KVM 的轻量虚拟化管理面板。它把常见宿主机运维动作收敛到 Web 控制台和命令行里,适合用来管理小型 VPS、独立服务器或需要批量分发容器访问权限的场景。
|
||||||
|
|
||||||
|
## 核心能力
|
||||||
|
|
||||||
|
- 管理 LXC 容器和 KVM 虚拟机。
|
||||||
|
- 创建、开机、关机、重启、重装、删除容器。
|
||||||
|
- 配置 CPU、内存、磁盘、流量限制和到期时间。
|
||||||
|
- 管理 NAT4 端口映射,并在宿主机具备 IPv6 路由时分配公网 IPv6。
|
||||||
|
- 在浏览器中打开 WebSSH 或 WebVNC。
|
||||||
|
- 管理镜像下载、启用状态和本地缓存。
|
||||||
|
- 创建、恢复、删除快照,配置计划快照和快照配额。
|
||||||
|
- 基于连接行为生成安全告警,并保留审计日志。
|
||||||
|
- 为指定容器创建子用户访问链接。
|
||||||
|
- 通过 API Key 接入 `/api/v1` 自动化接口。
|
||||||
|
|
||||||
|
## 适用场景
|
||||||
|
|
||||||
|
- 一台宿主机上需要快速分配多个 Linux 容器。
|
||||||
|
- 需要给用户临时发放容器控制台、SSH、VNC 或 NAT 端口管理权限。
|
||||||
|
- 希望用 API 自动化创建容器、调整资源、重置密码或回收资源。
|
||||||
|
- 需要一个比纯 CLI 更直观,但又不重型的平台面板。
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
- 后端:Go、`net/http`、SQLite、systemd、LXC、KVM/libvirt、cgroup v2、iptables、conntrack。
|
||||||
|
- 前端:React、TypeScript、Vite、Tailwind CSS、lucide-react、xterm.js、noVNC。
|
||||||
|
- 发布:GitHub Actions 构建 Linux AMD64 release 产物,安装脚本默认拉取最新 Release。
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# 快速上手
|
||||||
|
|
||||||
|
下面是一条从安装后到创建第一台容器的常用路径。
|
||||||
|
|
||||||
|
## 1. 登录控制台
|
||||||
|
|
||||||
|
访问 `http://YOUR_SERVER_IP:8999`,使用管理员账号登录。
|
||||||
|
|
||||||
|
进入面板后先检查:
|
||||||
|
|
||||||
|
- 控制面板是否显示主机资源。
|
||||||
|
- 镜像管理是否能列出模板。
|
||||||
|
- 路由管理中 NAT 和 IPv6 状态是否符合宿主机预期。
|
||||||
|
|
||||||
|
## 2. 下载镜像
|
||||||
|
|
||||||
|
进入“镜像管理”,选择需要的模板并下载。宿主机资源较小时,可以优先选择 Alpine、Debian 这类轻量镜像。
|
||||||
|
|
||||||
|
镜像下载是异步任务,可以在任务队列中观察进度。
|
||||||
|
|
||||||
|
## 3. 创建容器
|
||||||
|
|
||||||
|
进入“容器管理”,点击创建:
|
||||||
|
|
||||||
|
- 选择虚拟化类型和模板。
|
||||||
|
- 设置 CPU、内存、磁盘。
|
||||||
|
- 设置流量限制和到期时间。
|
||||||
|
- 如果需要外部访问,创建后到容器详情里添加 NAT 端口映射或分配 IPv6。
|
||||||
|
|
||||||
|
## 4. 打开终端
|
||||||
|
|
||||||
|
容器创建完成后,可以在详情页打开 WebSSH。KVM 虚拟机可使用 WebVNC 查看控制台。
|
||||||
|
|
||||||
|
## 5. 分发给子用户
|
||||||
|
|
||||||
|
如果需要把某个容器交给其他用户管理,进入“子用户管理”创建访问链接。子用户只会看到被授权的容器,并受到管理员配置的操作范围限制。
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# 升级
|
||||||
|
|
||||||
|
CLICD 的安装脚本和 CLI 都围绕 GitHub Release 产物工作。升级前建议先确认当前版本、备份配置和数据库。
|
||||||
|
|
||||||
|
## 查看版本
|
||||||
|
|
||||||
|
Web 面板侧边栏底部会显示当前版本,也可以访问:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://127.0.0.1:8999/api/version
|
||||||
|
```
|
||||||
|
|
||||||
|
返回示例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"version": "1.1.6"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 使用安装脚本升级
|
||||||
|
|
||||||
|
安装脚本默认使用最新 Release:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://raw.githubusercontent.com/MengMengCode/CLICD/main/install.sh | sudo sh
|
||||||
|
```
|
||||||
|
|
||||||
|
指定版本:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://raw.githubusercontent.com/MengMengCode/CLICD/main/install.sh | sudo CLICD_VERSION=v1.1.6 sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## 升级前检查
|
||||||
|
|
||||||
|
- 确认 `/root/.clicd/` 或实际配置目录已备份。
|
||||||
|
- 确认系统服务没有正在执行关键任务。
|
||||||
|
- 如果正在下载镜像或恢复快照,建议等待任务完成后再升级。
|
||||||
|
- 升级后检查 `systemctl status clicd` 和 Web 面板版本号。
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
---
|
||||||
|
layout: home
|
||||||
|
|
||||||
|
hero:
|
||||||
|
name: CLICD
|
||||||
|
text: 轻量 LXC/KVM 虚拟化管理面板
|
||||||
|
tagline: 提供 Web 控制台、CLI、容器编排、NAT/IPv6、快照、安全告警、子用户和 API 自动化能力。
|
||||||
|
actions:
|
||||||
|
- theme: brand
|
||||||
|
text: 开始安装
|
||||||
|
link: /guide/installation
|
||||||
|
- theme: alt
|
||||||
|
text: 查看 API
|
||||||
|
link: /features/api
|
||||||
|
|
||||||
|
features:
|
||||||
|
- title: 面向小型宿主机
|
||||||
|
details: 适合在单台 VPS 或独立服务器上管理 LXC 容器与 KVM 虚拟机。
|
||||||
|
- title: Web 与 CLI 并行
|
||||||
|
details: 管理员可使用 Web 面板,也可以进入 clicd CLI 完成维护操作。
|
||||||
|
- title: 自动化友好
|
||||||
|
details: /api/v1 提供容器、镜像、快照、安全、日志、子用户和 API Key 管理接口。
|
||||||
|
---
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# 部署建议
|
||||||
|
|
||||||
|
CLICD 可以直接运行在宿主机上,也可以放在反向代理之后。生产环境建议先做好访问控制,再开放给管理员使用。
|
||||||
|
|
||||||
|
## 服务暴露
|
||||||
|
|
||||||
|
默认 Web 端口为 `8999`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://YOUR_SERVER_IP:8999
|
||||||
|
```
|
||||||
|
|
||||||
|
建议:
|
||||||
|
|
||||||
|
- 仅允许固定管理员 IP 访问。
|
||||||
|
- 使用反向代理配置 HTTPS。
|
||||||
|
- 不要在公开文档或截图里暴露真实登录地址。
|
||||||
|
|
||||||
|
## systemd
|
||||||
|
|
||||||
|
常用命令:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl status clicd
|
||||||
|
systemctl restart clicd
|
||||||
|
systemctl enable clicd
|
||||||
|
journalctl -u clicd -f
|
||||||
|
```
|
||||||
|
|
||||||
|
## 防火墙
|
||||||
|
|
||||||
|
至少确认:
|
||||||
|
|
||||||
|
- 面板端口只对可信来源开放。
|
||||||
|
- NAT 映射端口按需开放。
|
||||||
|
- SSH 管理端口不与容器映射冲突。
|
||||||
|
- IPv6 防火墙规则与 IPv4 同步规划。
|
||||||
|
|
||||||
|
## 备份
|
||||||
|
|
||||||
|
建议定期备份:
|
||||||
|
|
||||||
|
- CLICD 配置目录。
|
||||||
|
- SQLite 数据库。
|
||||||
|
- 容器配置。
|
||||||
|
- 关键容器的快照或外部数据备份。
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# 常见问题
|
||||||
|
|
||||||
|
## 安装脚本默认安装哪个版本?
|
||||||
|
|
||||||
|
默认安装 GitHub Releases 的最新版本。脚本中默认值是 `CLICD_VERSION=latest`,会下载 `releases/latest` 下的 Linux AMD64 产物。
|
||||||
|
|
||||||
|
## 可以固定安装某个版本吗?
|
||||||
|
|
||||||
|
可以:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://raw.githubusercontent.com/MengMengCode/CLICD/main/install.sh | sudo CLICD_VERSION=v1.1.6 sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## 子用户能看到全部容器吗?
|
||||||
|
|
||||||
|
不能。子用户只会看到管理员授权给他的容器。
|
||||||
|
|
||||||
|
## API Key 和登录密码一样吗?
|
||||||
|
|
||||||
|
不一样。API Key 在“API 集成”页面创建,用于程序化调用接口。登录密码用于 Web 面板登录。
|
||||||
|
|
||||||
|
## 到达流量限制后会怎样?
|
||||||
|
|
||||||
|
容器达到流量限制后会被自动关机,避免继续产生超额流量。管理员可以调整限制或重置流量。
|
||||||
|
|
||||||
|
## IPv6 分配后为什么公网不通?
|
||||||
|
|
||||||
|
IPv6 是否可达取决于宿主机和上游网络。需要确认宿主机拥有可路由 IPv6 地址段,并且路由、防火墙、邻居发现或代理配置正确。
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# 故障排查
|
||||||
|
|
||||||
|
## 服务无法访问
|
||||||
|
|
||||||
|
检查服务状态:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl status clicd
|
||||||
|
journalctl -u clicd -n 100 --no-pager
|
||||||
|
```
|
||||||
|
|
||||||
|
检查端口监听:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ss -lntp | grep 8999
|
||||||
|
```
|
||||||
|
|
||||||
|
如果使用反向代理,请同时检查代理日志和上游地址。
|
||||||
|
|
||||||
|
## 镜像下载失败
|
||||||
|
|
||||||
|
- 确认宿主机可以访问镜像源和 GitHub Release。
|
||||||
|
- 检查磁盘空间。
|
||||||
|
- 在任务队列里查看失败原因。
|
||||||
|
- 如下载卡住,可尝试取消任务后重新下载。
|
||||||
|
|
||||||
|
## 容器无法联网
|
||||||
|
|
||||||
|
- 检查宿主机 NAT 和转发规则。
|
||||||
|
- 检查容器 IP 是否分配成功。
|
||||||
|
- 检查防火墙是否拦截转发流量。
|
||||||
|
- IPv6 场景下确认上游已经把地址段路由到宿主机。
|
||||||
|
|
||||||
|
## WebSSH 或 WebVNC 连接失败
|
||||||
|
|
||||||
|
- 确认容器或虚拟机正在运行。
|
||||||
|
- WebSSH 需要容器内 SSH 服务可用。
|
||||||
|
- WebVNC 需要 KVM 控制台可访问。
|
||||||
|
- 票据有效期很短,过期后重新创建即可。
|
||||||
|
|
||||||
|
## API 返回未授权
|
||||||
|
|
||||||
|
- 确认 API Key 没有被禁用。
|
||||||
|
- 确认请求头使用 `X-API-Key` 或 `Authorization: Bearer`。
|
||||||
|
- 确认密钥权限范围覆盖目标接口。
|
||||||
|
- 不要把面板登录密码当作 API Key 使用。
|
||||||
Generated
+2628
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"name": "clicd-docs",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vitepress dev . --host 127.0.0.1",
|
||||||
|
"build": "vitepress build .",
|
||||||
|
"preview": "vitepress preview . --host 127.0.0.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"vitepress": "^1.6.4"
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"vite": "6.4.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
cli.cd
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user