Compare commits

...

108 Commits

Author SHA1 Message Date
MengMengCode 73433c035d release: v1.1.28 2026-07-26 04:22:32 +08:00
MengMengCode 5474991a6d feat: implement safe HTTP client and URL validation for secure downloads 2026-07-26 04:19:38 +08:00
MengMengCode 37b16b83a5 Merge branch 'main' of https://github.com/MengMengCode/CLICD 2026-07-26 04:04:52 +08:00
MengMengCode 38debab1aa Add custom image handling and access policy management
- Implement tests for custom KVM and LXC image creation, ensuring invalid sources and architecture mismatches are rejected.
- Introduce access policy management in CLI, allowing configuration of allowed sources and trusted proxies.
- Add NAT network configuration with validation for RFC1918 compliance and subnet parsing.
- Create panel access policy management, including normalization and evaluation of access decisions based on client IPs and forwarded headers.
- Develop middleware for enforcing access policies in the server, returning appropriate responses for allowed and denied requests.
- Enhance custom image downloading and validation, ensuring integrity and security of downloaded root filesystem archives.
- Include comprehensive tests for all new functionalities to ensure reliability and correctness.
2026-07-26 04:04:45 +08:00
Meng Meng 24204609a1 Merge pull request #34 from MengMengCode/dependabot/npm_and_yarn/frontend/multi-92d8ff70db
build(deps): bump react-router and react-router-dom in /frontend
2026-07-24 19:21:40 +08:00
dependabot[bot] deedf86c22 build(deps): bump react-router and react-router-dom in /frontend
Bumps [react-router](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router) to 7.18.1 and updates ancestor dependency [react-router-dom](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-dom). These dependencies need to be updated together.


Updates `react-router` from 6.30.4 to 7.18.1
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/react-router@7.18.1/packages/react-router/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/react-router@7.18.1/packages/react-router)

Updates `react-router-dom` from 6.30.4 to 7.18.1
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/react-router-dom@7.18.1/packages/react-router-dom/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/react-router-dom@7.18.1/packages/react-router-dom)

---
updated-dependencies:
- dependency-name: react-router
  dependency-version: 7.18.1
  dependency-type: indirect
- dependency-name: react-router-dom
  dependency-version: 7.18.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-24 11:03:57 +00:00
MengMengCode 8283b88ded release: v1.1.27 2026-07-24 19:00:12 +08:00
MengMengCode 6c9f24bb24 FIX port setting 2026-07-24 18:51:52 +08:00
MengMengCode f2fa2449e9 release: v1.1.26 2026-07-22 17:02:23 +08:00
MengMengCode 53d56be8f9 fix #33 2026-07-22 16:57:44 +08:00
MengMengCode ec38ab9136 Merge branch 'main' of https://github.com/MengMengCode/CLICD 2026-07-21 21:26:09 +08:00
MengMengCode fdcd7df9e9 Update API endpoint. 2026-07-21 21:26:04 +08:00
Meng Meng d6d46296fe Merge pull request #32 from MengMengCode/dependabot/npm_and_yarn/frontend/axios-1.18.0
build(deps): bump axios from 1.17.0 to 1.18.0 in /frontend
2026-07-21 15:13:02 +08:00
dependabot[bot] 3f44c7565f build(deps): bump axios from 1.17.0 to 1.18.0 in /frontend
Bumps [axios](https://github.com/axios/axios) from 1.17.0 to 1.18.0.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.17.0...v1.18.0)

---
updated-dependencies:
- dependency-name: axios
  dependency-version: 1.18.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-20 20:30:35 +00:00
Meng Meng 61d842d94c Fix star history image link in README.md
Updated the star history image link in the README.
2026-07-20 12:52:40 +08:00
Meng Meng ca303d33f6 Replace star history section with new embed link 2026-07-19 20:08:13 +08:00
MengMengCode 6bdeafccf2 修复剩余 CodeQL 高危告警 2026-07-18 21:31:31 +08:00
MengMengCode 04cefe0cf1 release: v1.1.25 2026-07-18 21:18:54 +08:00
MengMengCode f28117bc5e 修复了一些已知问题 2026-07-18 21:16:29 +08:00
MengMengCode 2324494dd7 优化了机器创建流程 2026-07-18 20:45:23 +08:00
MengMengCode ebba97f1d6 · 修复了一些已知问题
· 增加了局域网DHCP IP分配适配
· 完善了多盘兼容支持 #17
2026-07-18 19:44:32 +08:00
MengMengCode 3dabd93d2f Fix some problem. 2026-07-17 00:44:47 +08:00
MengMengCode 8bad52bd9e FIX ##30 2026-07-17 00:27:31 +08:00
MengMengCode d05ca8cc4c release: v1.1.24 2026-07-16 23:28:28 +08:00
MengMengCode 48fa14f8a7 FIX #18 2026-07-16 23:27:26 +08:00
MengMengCode 5c6d6eafc9 Support Debian 13.
#29
and fix useage chart data display problem, storage on database instead of localstorage.
2026-07-16 21:24:28 +08:00
MengMengCode 2ecdb5c26f fix Dependabot alerts 2026-07-16 21:01:27 +08:00
MengMengCode ae02241370 KVM的端口转发_ 失效修复 2026-07-16 20:57:07 +08:00
MengMengCode fdd83977fc 支持限制用户可选择的系统 2026-07-16 20:47:26 +08:00
MengMengCode 58d86b5d08 release: v1.1.23 2026-07-16 18:57:49 +08:00
MengMengCode 7307255130 FIX #28 2026-07-16 18:57:15 +08:00
MengMengCode 0e3c059236 ci: fix release workflow checkout 2026-07-16 16:58:19 +08:00
MengMengCode 61137b837d release: v1.1.22 2026-07-16 16:58:19 +08:00
Meng Meng 596bf86477 Merge pull request #27 from MengMengCode/dependabot/go_modules/backend/golang.org/x/crypto-0.52.0
build(deps): bump golang.org/x/crypto from 0.45.0 to 0.52.0 in /backend
2026-07-16 15:15:39 +08:00
MengMengCode 47a09aa177 release: v1.1.21 2026-07-16 15:13:53 +08:00
MengMengCode 2456b65ce2 Merge branch 'main' of https://github.com/MengMengCode/CLICD 2026-07-16 15:13:40 +08:00
MengMengCode 292686a19a Fix some problems. 2026-07-16 15:13:35 +08:00
dependabot[bot] 9eb7c322cf build(deps): bump golang.org/x/crypto from 0.45.0 to 0.52.0 in /backend
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.45.0 to 0.52.0.
- [Commits](https://github.com/golang/crypto/compare/v0.45.0...v0.52.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-version: 0.52.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-10 11:56:11 +00:00
Meng Meng 5ec62ca732 Merge pull request #26 from StarVM-OpenSource/main
从UTF8-BOM转UTF8以修复魔方财务使用此模块后无法正常被下游拉取信息的问题
2026-06-29 09:06:10 +08:00
a79df0d2dd Merge branch 'MengMengCode:main' into main 2026-06-26 22:20:52 +08:00
cc8fdbfede 从UTF8-BOM转UTF8以修复魔方财务使用此模块后无法正常被下游拉取信息的问题 2026-06-26 20:44:29 +08:00
MengMengCode 702d6975e5 release: v1.1.20 2026-06-17 21:08:13 +08:00
MengMengCode 84d98e40c6 FIX #16 2026-06-17 21:06:58 +08:00
MengMengCode fd974d95b9 Merge branch 'main' of https://github.com/MengMengCode/CLICD 2026-06-17 20:41:14 +08:00
MengMengCode cd258fd6ac FIX #20 2026-06-17 20:41:11 +08:00
Meng Meng 0c2dd457d4 Merge pull request #22 from MengMengCode/dependabot/npm_and_yarn/frontend/form-data-4.0.6
build(deps): bump form-data from 4.0.5 to 4.0.6 in /frontend
2026-06-17 20:03:18 +08:00
MengMengCode 92e846eecc IPV6 fix 2026-06-17 19:56:09 +08:00
dependabot[bot] a1d9ce8b1c build(deps): bump form-data from 4.0.5 to 4.0.6 in /frontend
Bumps [form-data](https://github.com/form-data/form-data) from 4.0.5 to 4.0.6.
- [Release notes](https://github.com/form-data/form-data/releases)
- [Changelog](https://github.com/form-data/form-data/blob/master/CHANGELOG.md)
- [Commits](https://github.com/form-data/form-data/compare/v4.0.5...v4.0.6)

---
updated-dependencies:
- dependency-name: form-data
  dependency-version: 4.0.6
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-17 11:22:14 +00:00
MengMengCode 49d8093f45 网络流量、磁盘 IO 图表显示优化 2026-06-17 19:16:28 +08:00
MengMengCode 98ed716225 Merge branch 'main' of https://github.com/MengMengCode/CLICD 2026-06-17 18:42:19 +08:00
MengMengCode 78276d303b chore(docs): 升级 esbuild 至 0.28.1 并更新忽略规则 2026-06-17 18:42:12 +08:00
Meng Meng 30d2a4f4da Merge pull request #15 from StarVM-OpenSource/main
更新一键安装certbot脚本+增加更完整的webssh逻辑
2026-06-14 12:51:18 +08:00
a54e03b924 更新一键安装certbot脚本+增加更完整的webssh逻辑 2026-06-14 03:15:02 +08:00
Meng Meng 4cdc6e68ba Merge pull request #14 from MengMengCode/alert-autofix-29
Potential fix for code scanning alert no. 29: DOM text reinterpreted as HTML
2026-06-13 21:04:48 +08:00
Meng Meng 2ed42992ed Potential fix for code scanning alert no. 29: DOM text reinterpreted as HTML
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-06-13 21:04:10 +08:00
Meng Meng 4dfd7c0885 Merge pull request #13 from StarVM-OpenSource/main
修复防火墙无法创建 获取规则问题 优化防火墙创建规则UI
2026-06-13 12:01:43 +08:00
5ed5b4509d Merge branch 'main' of https://github.com/StarVM-OpenSource/CLICD-MoFang 2026-06-13 02:52:47 +08:00
18f297b988 修复防火墙功能 2026-06-13 02:49:23 +08:00
d5a236943b 修复防火墙至不支持的方法 等待Claude修复 2026-06-13 00:52:19 +08:00
MengMengCode c54f92f892 Merge branch 'main' of https://github.com/MengMengCode/CLICD 2026-06-12 21:55:17 +08:00
MengMengCode 86f0d079ab update docs 2026-06-12 21:55:15 +08:00
Meng Meng 3a65d5d24a Remove version number from README title
Removed version number from the title in README.md
2026-06-12 16:03:38 +08:00
MengMengCode cbe9339316 release: v1.1.19 2026-06-12 16:01:52 +08:00
Meng Meng 79d6dad684 Merge pull request #12 from MengMengCode/chore/update-version-badge
chore: add version badge to README
2026-06-12 15:59:47 +08:00
MengMengCode 55c7a9796c chore: add version badge to README 2026-06-12 15:59:24 +08:00
Meng Meng f4a15a0d90 Merge pull request #11 from MengMengCode/feature/split-rate-limits
feat: split rate limits with configurable per-endpoint controls
2026-06-12 15:57:35 +08:00
MengMengCode c7742319b2 feat: split rate limits with configurable per-endpoint controls 2026-06-12 15:53:31 +08:00
Meng Meng 01c14ecba6 chore: update firewall i18n strings
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 13:42:22 +08:00
MengMengCode 989e1b6645 chore: update firewall i18n strings
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 13:40:59 +08:00
MengMengCode da5eea5193 release: v1.1.18 2026-06-12 13:19:07 +08:00
MengMengCode 4de86c458f 修复了一些已知问题 2026-06-12 13:18:54 +08:00
Meng Meng baf213e769 Potential fix for code scanning alert no. 28: DOM text reinterpreted as HTML
Patch

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-06-12 12:00:38 +08:00
Meng Meng 819a79e00d Merge pull request #8 from StarVM-OpenSource/main
魔方财务模块:支持独立IPv4 初步兼容防火墙
2026-06-12 11:54:46 +08:00
18bee369c1 Merge branch 'main' of https://github.com/StarVM-OpenSource/CLICD-MoFang 2026-06-12 10:20:59 +08:00
2463715e32 兼容独立IPv4 初步兼容防火墙 2026-06-12 10:19:51 +08:00
MengMengCode fbc539ea47 release: v1.1.17 2026-06-12 01:35:17 +08:00
MengMengCode 875cd4716b 增加了服务器防火墙功能 2026-06-12 01:35:04 +08:00
MengMengCode 6194b6e364 修复了一些已知问题 2026-06-11 23:29:22 +08:00
MengMengCode 30d6b2d9f7 release: v1.1.16 2026-06-11 23:09:49 +08:00
MengMengCode 2f94498df2 修复了一些已知问题 2026-06-11 23:09:04 +08:00
MengMengCode c303fe6d17 release: v1.1.15 2026-06-10 14:52:30 +08:00
MengMengCode eedb2d7fb0 修复了一些已知问题 2026-06-10 14:51:22 +08:00
MengMengCode bfc98d043b feat(security): 快照并持久化告警时的连接跟踪数据 2026-06-10 13:28:46 +08:00
MengMengCode 0a4cf5c0bd release: v1.1.14 2026-06-10 11:12:43 +08:00
MengMengCode 54a608c19d Merge branch 'main' of https://github.com/MengMengCode/CLICD 2026-06-10 11:12:32 +08:00
MengMengCode 73bd6934f9 修复预设密码以及KEY问题 2026-06-10 11:12:27 +08:00
Meng Meng a35595edcf Update README.md 2026-06-10 11:04:58 +08:00
MengMengCode b605df613e Merge branch 'main' of https://github.com/MengMengCode/CLICD 2026-06-10 10:35:13 +08:00
MengMengCode 3cc8f7df7f 修复了一些已知问题 2026-06-10 10:35:08 +08:00
Meng Meng 744246c0a8 Update thanks section formatting in README.md 2026-06-10 09:33:31 +08:00
MengMengCode aed512cb09 patch 2026-06-10 02:39:58 +08:00
MengMengCode b6f48acc4c Refactor code structure for improved readability and maintainability 2026-06-10 02:33:18 +08:00
MengMengCode d83a5e3473 release: v1.1.13 2026-06-10 01:27:21 +08:00
MengMengCode 18c9d75a05 Merge pull request #5 from StarVM-OpenSource/main
Add Mofang finance module sources without packaged zip.
2026-06-10 01:22:45 +08:00
MengMengCode b80e4817ef ci: package mofang module artifacts 2026-06-10 01:21:57 +08:00
MengMengCode 0b052f2217 优化UI 2026-06-10 01:14:51 +08:00
80386d35ba 已测试LXC功能基本正常 KVM功能暂未进行测试 2026-06-10 01:01:39 +08:00
MengMengCode a45e063fc2 Add comprehensive documentation for CLICD features and operations
- Introduced Container Management documentation covering lifecycle operations, resource management, and console access.
- Added Dashboard documentation detailing metrics and related APIs.
- Created Host Report documentation summarizing host environment and resource status.
- Included Image Management documentation for template handling and management actions.
- Documented Networking and Routing features including NAT4 and IPv6 management.
- Added Security Alerts documentation outlining alert scenarios and API usage.
- Created Snapshot Management documentation for snapshot operations and scheduling.
- Documented Sub-user management for granting access to specific containers.
- Added Configuration guide detailing runtime settings and security recommendations.
- Created Installation guide for setting up CLICD with requirements and steps.
- Added Introduction and Quick Start guides for new users.
- Documented Upgrade process with version checking and pre-upgrade checklist.
- Created Deployment guide for service exposure and firewall recommendations.
- Added FAQ section addressing common questions and concerns.
- Documented Troubleshooting steps for common issues encountered.
2026-06-10 00:42:06 +08:00
MengMengCode e71adf6830 修复了一些已知问题 2026-06-09 23:34:09 +08:00
MengMengCode bb0c4f999d release: v1.1.12 2026-06-09 23:25:29 +08:00
MengMengCode 0c9f420474 添加新建虚拟机/重装系统 预设SSH密码以及KEY Auth功能 2026-06-09 23:25:19 +08:00
MengMengCode 9a826add87 release: v1.1.11 2026-06-09 22:48:20 +08:00
MengMengCode 63611dc932 Support webSSH Origin Allowlist 2026-06-09 22:48:09 +08:00
MengMengCode e6551bf4ae release: v1.1.10 2026-06-09 22:25:42 +08:00
MengMengCode 5bf2b6534a Merge branch 'main' of https://github.com/MengMengCode/CLICD 2026-06-09 22:22:42 +08:00
MengMengCode 917afc3157 支持公网IPV4分配,单独IPV6分配,以及混合网络分配。 2026-06-09 22:22:40 +08:00
Meng Meng c8081edbac Update README.md 2026-06-09 16:50:19 +08:00
Meng Meng 95eb00a31d Update Buy Me a Coffee username in FUNDING.yml 2026-06-09 15:56:08 +08:00
137 changed files with 29040 additions and 2479 deletions
+15
View File
@@ -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']
+79 -23
View File
@@ -6,7 +6,7 @@ on:
- main
- master
tags:
- "v*"
- 'v*'
pull_request:
workflow_dispatch:
@@ -14,9 +14,15 @@ permissions:
contents: write
jobs:
linux-amd64:
name: Linux amd64
linux:
name: Linux ${{ matrix.goarch }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
goarch:
- amd64
- arm64
steps:
- name: Checkout
@@ -25,51 +31,101 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
node-version: 20
cache: npm
cache-dependency-path: frontend/package-lock.json
cache-dependency-path: |
frontend/package-lock.json
docs/package-lock.json
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: "1.22.x"
go-version: '1.24.5'
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
shell: bash
run: |
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
echo "CLICD_VERSION=dev" >> $GITHUB_ENV
echo "CLICD_VERSION=dev" >> "$GITHUB_ENV"
fi
- name: Build
shell: bash
- name: Build CLICD
env:
CLICD_GOARCH: ${{ matrix.goarch }}
run: bash build.sh
- name: Package
shell: bash
- name: Package CLICD
env:
CLICD_GOARCH: ${{ matrix.goarch }}
run: |
mkdir -p dist package/clicd-linux-amd64
cp build/clicd package/clicd-linux-amd64/clicd
cp build/install.sh 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
cp build/clicd dist/clicd-linux-amd64
sha256sum dist/* > dist/SHA256SUMS
asset_dir="clicd-linux-${CLICD_GOARCH}"
mkdir -p "dist" "package/${asset_dir}"
cp build/clicd "package/${asset_dir}/clicd"
cp build/install.sh "package/${asset_dir}/install.sh"
chmod +x "package/${asset_dir}/clicd" "package/${asset_dir}/install.sh"
tar -C package -czf "dist/${asset_dir}.tar.gz" "${asset_dir}"
cp build/clicd "dist/${asset_dir}"
- 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
uses: actions/upload-artifact@v4
with:
name: clicd-linux-amd64
name: clicd-linux-${{ matrix.goarch }}
path: dist/*
release:
name: Publish GitHub Release
needs: linux
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/v')
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Download artifacts
uses: actions/download-artifact@v4
with:
path: dist-artifacts
- name: Prepare release assets
run: |
mkdir -p dist
find dist-artifacts -maxdepth 2 -type f ! -name SHA256SUMS -print -exec cp -f {} dist/ \;
sha256sum dist/* > dist/SHA256SUMS
- name: Publish GitHub Release
if: startsWith(github.ref, 'refs/tags/v')
env:
GH_TOKEN: ${{ github.token }}
shell: bash
GH_REPO: ${{ github.repository }}
run: |
gh release create "$GITHUB_REF_NAME" dist/* --generate-notes || \
gh release upload "$GITHUB_REF_NAME" dist/* --clobber
+6
View File
@@ -13,6 +13,8 @@ backend/internal/server/web/*
# Build artifacts
/build/
/dist/
Mofang/*.zip
*.exe
*.dll
*.so
@@ -67,3 +69,7 @@ linux.txt
push-release.ps1
deploy.ps1
backend/clicd
api.md
deploy-arm.ps1
deploy-dhcp.ps1
deploy-pve-windows.ps1
+549
View File
@@ -0,0 +1,549 @@
# CLICD 魔方财务对接模块
这是用于智简魔方 / IDCSMART 的 CLICD 服务器模块。模块通过 CLICD API 完成实例开通、删除、开关机、重启、重装、改密、资源变更、流量重置、NAT 端口映射管理、实例信息展示和 WebSSH 入口。
## 文件结构
```text
clicd.php
README.md
handlers/
webssh.php
templates/
firewall.html
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"
}
```
## 防火墙
防火墙是独立客户区页面,支持:
- 查看防火墙启用状态、默认动作和规则列表
- 启用 / 停用防火墙
- 设置默认动作:未匹配拒绝或未匹配放行
- 添加规则
- 编辑规则
- 删除规则
- 单独启用 / 停用某条规则
页面会先在前端修改规则列表和开关状态,点击“保存设置”后才统一同步到 CLICD。这样可以避免每次切换开关、修改默认动作或编辑规则时都立即请求后端,减少客户区卡顿。
注意:防火墙关闭时也可以保存规则;关闭只表示暂时不接管该容器流量,不代表规则必须清空。
使用的 CLICD API
```text
GET /api/v1/containers/{id}/firewall
PUT /api/v1/containers/{id}/firewall
```
更新防火墙时必须使用 JSON 请求体,例如:
```json
{
"enabled": true,
"default_action": "ACCEPT",
"rules": [
{
"id": "",
"network": "ipv4",
"direction": "in",
"protocol": "tcp",
"port": "22",
"source_ip": "",
"action": "ACCEPT",
"description": "Allow SSH",
"enabled": true
}
]
}
```
规则字段说明:
| 字段 | 说明 |
| --- | --- |
| `network` | 网络范围,常用 `ipv4`,也支持 `ipv6` / `all` |
| `direction` | 方向,`in` 入站,`out` 出站 |
| `protocol` | 协议,`tcp``udp` |
| `port` | 端口,可填写单端口、逗号分隔端口或端口段,例如 `22``80,443``8000-9000` |
| `source_ip` | 来源 IP / CIDR,留空表示任意来源 |
| `action` | 动作,`ACCEPT` 放行,`DROP` 拒绝 |
| `description` | 规则描述 |
| `enabled` | 是否启用该规则 |
IPv4 NAT 入站规则的端口按容器内部端口匹配,不是宿主机公网端口。例如公网 `22023 -> 容器 22`,防火墙规则端口应填写 `22`
## 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` |
| 查询防火墙 | `GET /api/v1/containers/{id}/firewall` |
| 更新防火墙 | `PUT /api/v1/containers/{id}/firewall` |
| 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
container:firewall
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"}'
```
查询防火墙:
```bash
curl -H "X-API-Key: clicd_sk_xxxx" \
https://0.0.0.0:8999/api/v1/containers/10/firewall
```
更新防火墙:
```bash
curl --location --request PUT \
"https://0.0.0.0:8999/api/v1/containers/10/firewall" \
--header "X-API-Key: clicd_sk_xxxx" \
--header "Content-Type: application/json" \
--data-raw '{"enabled":true,"default_action":"ACCEPT","rules":[{"id":"","network":"ipv4","direction":"in","protocol":"tcp","port":"22","source_ip":"","action":"ACCEPT","description":"Allow SSH","enabled":true}]}'
```
创建 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
```
### 防火墙获取提示“不支持的方法”
请确认模块版本已经包含防火墙页签修复。客户区防火墙列表应通过模块公开的 `firewallList` 调用,再由模块向 CLICD 发起:
```text
GET /api/v1/containers/{id}/firewall
```
如果页面或二开代码直接把读取请求改成 `POST /api/v1/containers/{id}/firewall`,CLICD 会返回“不支持的方法”。
### 防火墙保存后规则为空
请确认更新接口最终发往 CLICD 的请求体是 JSON,并且包含 `rules` 数组。防火墙关闭时也可以保存规则,`enabled: false` 不应自动清空 `rules`
正确请求体示例:
```json
{
"enabled": false,
"default_action": "ACCEPT",
"rules": [
{
"id": "",
"network": "ipv4",
"direction": "in",
"protocol": "tcp",
"port": "22",
"source_ip": "",
"action": "ACCEPT",
"description": "Allow SSH",
"enabled": true
}
]
}
```
### 图表刚打开只有一条横线
CLICD 当前用量接口返回的是实时值,不是历史序列。页面刚打开时只有一个采样点,所以会显示当前值横线。选择 `10 秒` 自动刷新或点击“立即刷新”多采样几次后,会逐步形成折线。
### 流量显示为 0
旧版本只显示 GB,小流量换算后会被四舍五入成 `0 GB`。当前版本已改为智能单位,会显示 B / KB / MB / GB。
### 防火墙
防火墙是独立客户区页面,支持:
- 查看防火墙启用状态、默认动作和规则列表
- 启用 / 停用防火墙
- 设置默认动作:未匹配拒绝或未匹配放行
- 添加规则
- 编辑规则
- 删除规则
- 单独启用 / 停用某条规则
页面会先在前端修改规则列表和开关状态,点击“保存设置”后才统一同步到 CLICD。这样可以避免每次切换开关、修改默认动作或编辑规则时都立即请求后端,减少客户区卡顿。
注意:防火墙关闭时也可以保存规则;关闭只表示暂时不接管该容器流量,不代表规则必须清空。
使用的 CLICD API
```text
GET /api/v1/containers/{id}/firewall
PUT /api/v1/containers/{id}/firewall
```
更新防火墙时必须使用 JSON 请求体,例如:
```json
{
"enabled": true,
"default_action": "ACCEPT",
"rules": [
{
"id": "",
"network": "ipv4",
"direction": "in",
"protocol": "tcp",
"port": "22",
"source_ip": "",
"action": "ACCEPT",
"description": "Allow SSH",
"enabled": true
}
]
}
```
规则字段说明:
| 字段 | 说明 |
| --- | --- |
| `network` | 网络范围,常用 `ipv4`,也支持 `ipv6` / `all` |
| `direction` | 方向,`in` 入站,`out` 出站 |
| `protocol` | 协议,`tcp``udp` |
| `port` | 端口,可填写单端口、逗号分隔端口或端口段,例如 `22``80,443``8000-9000` |
| `source_ip` | 来源 IP / CIDR,留空表示任意来源 |
| `action` | 动作,`ACCEPT` 放行,`DROP` 拒绝 |
| `description` | 规则描述 |
| `enabled` | 是否启用该规则 |
IPv4 NAT 入站规则的端口按容器内部端口匹配,不是宿主机公网端口。例如公网 `22023 -> 容器 22`,防火墙规则端口应填写 `22`
## 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。
+1716
View File
File diff suppressed because it is too large Load Diff
+399
View File
@@ -0,0 +1,399 @@
<?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'] : '');
$ticket = isset($_GET['ticket']) ? (string)$_GET['ticket'] : (isset($_GET['amp;ticket']) ? (string)$_GET['amp;ticket'] : '');
if ($protocol === '' && $ticket !== '') {
$protocol = 'clicd-ticket.' . $ticket;
}
if ($ws === '') {
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 ticket = <?php echo json_encode($ticket, 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;
}
function websocketProtocolValue(value) {
value = String(value || '');
return /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/.test(value) ? value : '';
}
try {
var protocolValue = websocketProtocolValue(protocol);
if (!protocolValue && ticket) {
append('[WebSSH] 票据已通过 URL 参数传递,当前浏览器不会发送子协议。\n');
}
socket = protocolValue ? new WebSocket(wsUrl, protocolValue) : new WebSocket(wsUrl);
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>
File diff suppressed because it is too large Load Diff
+378
View File
@@ -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">&#23454;&#20363;&#21517;&#31216;</div>
<div class="clicd-mini-value">{$container.name|default='-'}</div>
</div>
<div class="clicd-mini">
<div class="clicd-mini-label">&#36816;&#34892;&#29366;&#24577;</div>
<div class="clicd-mini-value">{$status_text|default='-'}</div>
</div>
<div class="clicd-mini">
<div class="clicd-mini-label">SSH &#22320;&#22336;</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>&#29366;&#24577;</span>
<span class="clicd-refresh">
<span>&#26356;&#26032;&#20110; <span data-clicd-info="chart_time">-</span></span>
<label>
&#33258;&#21160;&#21047;&#26032;
<select class="clicd-refresh-select" id="clicd-info-refresh">
<option value="0" selected>&#19981;&#21047;&#26032;</option>
<option value="10000">10 &#31186;</option>
<option value="60000">1 &#20998;&#38047;</option>
<option value="300000">5 &#20998;&#38047;</option>
<option value="600000">10 &#20998;&#38047;</option>
</select>
</label>
<button class="clicd-refresh-btn" type="button" id="clicd-info-refresh-now">&#31435;&#21363;&#21047;&#26032;</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">&#20869;&#23384;</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">&#36127;&#36733;</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">&#30913;&#30424;</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>&#26376;&#27969;&#37327;</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>&#20837;&#31449; <span data-clicd-info="traffic_in_text">{$traffic_in_text|default='-'}</span></div>
<div>&#20986;&#31449; <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>&#32479;&#35745;&#20449;&#24687;</span></div>
<div class="clicd-charts">
<div class="clicd-chart">
<div class="clicd-chart-title">CPU &#20351;&#29992;&#29575; <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">&#20869;&#23384;&#20351;&#29992; <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">&#32593;&#32476;&#27969;&#37327; <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">&#30913;&#30424; 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>&#23454;&#20363;&#20449;&#24687;</span></div>
<table class="clicd-table">
<tbody>
<tr>
<th>IPv4</th><td>{$ipv4|default='-'}</td>
<th>&#29992;&#25143;&#21517;</th><td>root</td>
</tr>
<tr>
<th>SSH &#31471;&#21475;</th><td>{$ssh_port|default='-'}</td>
<th>SSH &#23494;&#30721;</th><td>{$ssh_password|default='-'}</td>
</tr>
<tr>
<th>CPU</th><td>{$vcpu|default='-'} &#26680;</td>
<th>&#20869;&#23384;</th><td>{$ram_mb|default='-'} MB</td>
</tr>
<tr>
<th>&#30828;&#30424;</th><td>{$disk_gb|default='-'} GB</td>
<th>&#24102;&#23485;</th><td>{$bandwidth|default='-'} Mbps</td>
</tr>
<tr>
<th>&#21040;&#26399;&#26102;&#38388;</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>
+328
View File
@@ -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">&#23454;&#20363;&#21517;&#31216;</div>
<div class="clicd-nat-value">{$container_name}</div>
</div>
<div class="clicd-nat-card">
<div class="clicd-nat-label">&#20844;&#32593;&#22320;&#22336;</div>
<div class="clicd-nat-value">{$nat_host}</div>
</div>
<div class="clicd-nat-card">
<div class="clicd-nat-label">SSH &#31471;&#21475;</div>
<div class="clicd-nat-value">{$ssh_port}</div>
</div>
</div>
<div class="clicd-nat-title">&#28155;&#21152;&#31471;&#21475;&#26144;&#23556;</div>
<div class="clicd-nat-form">
<div class="clicd-nat-row">
<div class="clicd-nat-field">
<label>&#20844;&#32593;&#31471;&#21475;</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>&#23481;&#22120;&#31471;&#21475;</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>&#21327;&#35758;</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>&#35828;&#26126;</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">&#28155;&#21152;</button>
<button class="clicd-nat-btn clicd-nat-btn-secondary" type="button" data-clicd-action="random-port">&#33719;&#21462;&#38543;&#26426;&#31471;&#21475;</button>
</div>
</div>
</div>
<div class="clicd-nat-title">&#29616;&#26377;&#31471;&#21475;&#26144;&#23556;</div>
<div id="clicd-nat-list" class="clicd-nat-list">
{if condition="empty($port_mappings)"}
<div class="clicd-nat-form clicd-nat-muted">&#26242;&#26080;&#31471;&#21475;&#26144;&#23556;</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>&#32034;&#24341;</label>
<div class="clicd-nat-value">{$mapping.index}</div>
</div>
<div class="clicd-nat-field">
<label>&#20844;&#32593;&#35775;&#38382;</label>
<div class="clicd-nat-value">{$nat_host}:{$mapping.host_port}</div>
</div>
<div class="clicd-nat-field">
<label>&#20844;&#32593;&#31471;&#21475;</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>&#23481;&#22120;&#31471;&#21475;</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>&#21327;&#35758;</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>&#35828;&#26126;</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">&#20445;&#23384;</button>
<button class="clicd-nat-btn clicd-nat-btn-danger" type="button" data-clicd-action="delete">&#21024;&#38500;</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">&#30830;&#35748;&#21024;&#38500;</div>
<div class="clicd-nat-modal-body" id="clicd-nat-delete-text">&#30830;&#35748;&#21024;&#38500;&#35813;&#31471;&#21475;&#26144;&#23556;&#65311;</div>
<div class="clicd-nat-modal-actions">
<button class="clicd-nat-btn clicd-nat-btn-secondary" type="button" id="clicd-nat-delete-cancel">&#21462;&#28040;</button>
<button class="clicd-nat-btn clicd-nat-btn-danger" type="button" id="clicd-nat-delete-confirm">&#21024;&#38500;</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">&#26242;&#26080;&#31471;&#21475;&#26144;&#23556;</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>&#32034;&#24341;</label><div class="clicd-nat-value">' + index + '</div></div>' +
'<div class="clicd-nat-field"><label>&#20844;&#32593;&#35775;&#38382;</label><div class="clicd-nat-value">' + escapeHtml(natHost) + ':' + hostPort + '</div></div>' +
'<div class="clicd-nat-field"><label>&#20844;&#32593;&#31471;&#21475;</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>&#23481;&#22120;&#31471;&#21475;</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>&#21327;&#35758;</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>&#35828;&#26126;</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">&#20445;&#23384;</button>' +
'<button class="clicd-nat-btn clicd-nat-btn-danger" type="button" data-clicd-action="delete">&#21024;&#38500;</button></div>' +
'</div></div>';
}).join('');
}
function escapeHtml(value) {
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
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>
+54 -55
View File
@@ -2,19 +2,10 @@
<img src="frontend/public/favicon.svg" width="96" alt="CLICD">
</p>
<h1 align="center">CLICD</h1>
<h1 align="center">CLICD <sub></sub></h1>
<p align="center">
<img alt="Release" src="https://img.shields.io/github/v/release/MengMengCode/CLICD?style=flat-square">
<img alt="Stars" src="https://img.shields.io/github/stars/MengMengCode/CLICD?style=flat-square">
<img alt="Forks" src="https://img.shields.io/github/forks/MengMengCode/CLICD?style=flat-square">
<img alt="Downloads" src="https://img.shields.io/github/downloads/MengMengCode/CLICD/total?style=flat-square">
<img alt="Last Commit" src="https://img.shields.io/github/last-commit/MengMengCode/CLICD?style=flat-square">
<img alt="License" src="https://img.shields.io/github/license/MengMengCode/CLICD.svg?style=flat-square">
</p>
<p align="center">
<img alt="Go" src="https://img.shields.io/badge/Go-1.22-00ADD8?style=flat-square&logo=go&logoColor=white">
<img alt="Go" src="https://img.shields.io/badge/Go-1.24-00ADD8?style=flat-square&logo=go&logoColor=white">
<img alt="React" src="https://img.shields.io/badge/React-18-61DAFB?style=flat-square&logo=react&logoColor=111111">
<img alt="TypeScript" src="https://img.shields.io/badge/TypeScript-5-3178C6?style=flat-square&logo=typescript&logoColor=white">
<img alt="Vite" src="https://img.shields.io/badge/Vite-5-646CFF?style=flat-square&logo=vite&logoColor=white">
@@ -36,41 +27,11 @@
<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 is a lightweight virtualization management panel for LXC and KVM. It combines a web console, CLI tools, REST API, NAT/IPv6 networking, WebSSH/WebVNC access, resource quotas, traffic limits, snapshots, delegated sub-user access, and security alerts into a single deployable service.
CLICD 是一个面向 LXC/KVM 的轻量虚拟化管理面板,提供 Web 控制台、CLI、批量任务、镜像管理、NAT 端口、IPv6 分配、WebSSHVNC、资源限制、流量限制和安全告警能力。它适合用来管理小型 VPS 上的 LXC 容器和 KVM 虚拟机,也适合需要批量创建和分发子用户管理链接的场景。
CLICD 是一个面向 LXC/KVM 的轻量虚拟化管理面板,集成 Web 控制台、CLI、REST API、NAT/IPv6 网络、WebSSH/WebVNC、资源配额、流量限制、快照、子用户授权和安全告警能力,适合 VPS 商家、实验室、开发者自建虚拟化节点以及需要批量开通容器的场景。
## Features / 功能介绍
### English
1. Supports Ubuntu, Debian, Alpine, CentOS, Arch Linux, Fedora, Rocky Linux, and other operating system images. Images can be downloaded on demand through the image management interface. For hosts with limited resources, lightweight distributions such as Alpine are recommended.
2. Supports WebSSH management, allowing users to access container terminals directly from the browser without manually copying SSH credentials.
3. Supports NAT4 port quotas, port forwarding, and protocol restrictions, as well as public IPv6 allocation. IPv6 assignment requires the host machine to have a routable IPv6 prefix.
4. Supports both inbound and outbound traffic limits. Containers are automatically powered off when configured limits are reached, preventing bandwidth overuse.
5. Supports container expiration dates. Expired containers are automatically shut down, and delegated users lose access until an administrator extends the expiration period.
6. Includes lightweight conntrack-based security monitoring. The system does not store full logs of normal connections, but generates audit alerts for suspicious activities such as port scanning, lateral scanning, brute-force attempts, SMTP abuse, UDP reflection attacks, cryptocurrency mining ports, and proxy/VPN/Tor usage.
7. Supports delegated management links. Administrators can assign specific containers to sub-users, while ensuring that each user can only manage the containers explicitly authorized to them.
8. Provides a REST API for automating the management of containers, tasks, images, networking, traffic controls, and security alerts.
9. Supports operating entirely through the CLI. When the web console is not required, administrators can stop and disable the systemd service and launch CLI-only mode using `clicd cli --no-web`.
### 中文
1. 支持 Ubuntu、Debian、Alpine、CentOS、Arch Linux、Fedora、Rocky Linux 等系统镜像。镜像可以在镜像管理中按需下载;如果宿主机资源比较小,建议优先选择 Alpine 这类轻量镜像。
2. 支持 WebSSH 管理,可以在浏览器里一键进入容器终端,不需要手动复制 SSH 密码。
3. 支持设置 NAT4 端口数量、NAT 端口映射和协议限制,并支持分配公网 IPv6。IPv6 分配要求宿主机本身拥有可路由的 IPv6 地址段。
4. 支持单向和双向网络流量限制。达到限制后容器会自动关机,避免流量超额。
5. 支持设置容器有效期。到期后容器会自动关机,子用户无法继续操作,只有管理员重新设置延期日期后才能恢复使用。
6. 内置基于 conntrack 的轻量安全告警。系统不会保存完整正常连接日志,但会对端口扫描、横向扫描、爆破倾向、SMTP 滥用、UDP 反射、挖矿端口、代理/VPN/Tor 等可疑行为生成告警并写入审计日志。
7. 支持子用户管理链接,管理员可以把指定容器分发给拼车用户,子用户只能管理自己被授权的容器。
8. 支持 API 接入,可以通过 API 完成容器、任务、镜像、端口、流量、安全告警等功能的自动化控制。
9. 支持仅使用 CLI 管理。需要关闭 Web 控制台时,可以停止并禁用 systemd 服务,然后使用 `clicd cli --no-web` 进入命令行模式。
## Technology Stack / 技术栈
- Backend: Go, net/http, LXC, KVM/libvirt, cgroup v2, iptables, conntrack
- Frontend: React, TypeScript, Vite, Tailwind CSS, lucide-react, xterm.js
- Runtime: Linux, systemd, LXC, KVM/QEMU
- Build: GitHub Actions, Node.js 20, Go 1.22
![alt text](/img/image-1.png)
## Installation / 安装
@@ -86,9 +47,52 @@ One-click Uninstall / 一键卸载:
curl -fsSL https://raw.githubusercontent.com/MengMengCode/CLICD/main/install.sh | sudo sh -s -- uninstall
```
![alt text](/img/image.png)
![alt text](/img/image-1.png)
## Features / 功能介绍
### English
| Area | What CLICD provides |
| --- | --- |
| Virtualization | Manage LXC containers and KVM virtual machines from one panel, including create, reinstall, start, stop, restart, delete, password reset, expiry control, and batch actions. |
| Images and templates | Built-in template and image management for Ubuntu, Debian, Alpine, CentOS, Arch Linux, Fedora, Rocky Linux, and other common distributions. Images can be enabled, disabled, downloaded, cancelled, or removed from cache. |
| Networking | NAT4 port quotas, random available port allocation, TCP/UDP port mappings, public IPv4 pool management, IPv6 prefix detection, IPv6 status checks, and per-container IPv6 assignment. |
| Resource control | CPU, memory, disk, swap, bandwidth usage, traffic reset, traffic limit, and resource limit management, with automatic shutdown behavior for expired or over-quota containers. |
| Console access | Browser-based WebSSH and WebVNC ticket access, so users can open terminals or consoles without manually exchanging credentials. |
| Snapshots | Snapshot overview, per-container snapshots, create/delete/restore operations, scheduled snapshots, and quota controls. |
| Security | Conntrack-based security alerts for port scans, lateral scans, brute-force behavior, SMTP abuse, UDP reflection, mining ports, proxy/VPN/Tor usage, plus security logs, summaries, and configurable settings. |
| Accounts and audit | Delegated sub-user links, sub-user password rotation, per-user container permissions, audit logs, login logs, and API key management. |
| Automation | Versioned REST API under `/api/v1`, task queue endpoints, batch create/action endpoints, and a Mofang finance integration module packaged automatically by GitHub Actions. |
| Operations | Dashboard statistics, host resource overview, routing overview, swap management, CLI-only mode, and release artifacts generated by GitHub Actions. |
### 中文
| 模块 | CLICD 提供的能力 |
| --- | --- |
| 虚拟化管理 | 在同一个面板里管理 LXC 容器和 KVM 虚拟机,支持创建、重装、开机、关机、重启、删除、重置密码、到期时间和批量操作。 |
| 镜像与模板 | 内置模板和镜像管理,支持 Ubuntu、Debian、Alpine、CentOS、Arch Linux、Fedora、Rocky Linux 等常见发行版,镜像可按需下载、取消、启用、禁用和清理缓存。 |
| 网络能力 | 支持 NAT4 端口配额、随机可用端口、TCP/UDP 端口映射、公网 IPv4 池管理、IPv6 前缀检测、IPv6 状态检查和容器级 IPv6 分配。 |
| 资源限制 | 支持 CPU、内存、磁盘、Swap、独立上行/下行带宽、读/写 I/O 限速、流量重置、流量限制和资源限制管理;容器到期或超额后可自动关机,避免资源和流量失控。 |
| 远程控制 | 内置 WebSSH 和 WebVNC 票据访问,用户可以直接在浏览器打开终端或控制台,不需要手动复制连接信息。 |
| 快照能力 | 支持快照总览、容器快照、创建快照、删除快照、恢复快照、计划快照和快照配额。 |
| 安全告警 | 基于 conntrack 做轻量安全检测,可识别端口扫描、横向扫描、爆破倾向、SMTP 滥用、UDP 反射、挖矿端口、代理/VPN/Tor 等风险,并提供安全日志、汇总和设置项。 |
| 账号与审计 | 支持子用户管理链接、子用户密码轮换、按容器授权、操作日志、登录日志和 API Key 管理,适合分发给下游用户或拼车用户。 |
| 自动化接入 | 全量接口统一使用 `/api/v1`,覆盖任务队列、容器、镜像、网络、流量、安全、批量创建和批量操作;同时提供魔方财务对接模块,并由 GitHub Actions 自动打包发布。 |
| 运维入口 | 提供总览统计、主机资源、路由概览、Swap 管理、CLI-only 模式和 GitHub Actions 自动发布产物,便于在小型节点上长期维护。 |
## Technology Stack / 技术栈
- Backend: Go, net/http, LXC, KVM/libvirt, cgroup v2, iptables, conntrack
- Frontend: React, TypeScript, Vite, Tailwind CSS, lucide-react, xterm.js
- Runtime: Linux, systemd, LXC, KVM/QEMU
- Build: GitHub Actions, Node.js 20, Go 1.24
## Preview / 预览
![alt text](/img/image-2.png)
![alt text](/img/image-3.png)
![alt text](/img/image-4.png)
![alt text](/img/image-5.png)
## Disclaimer/免责声明
@@ -108,16 +112,11 @@ This open-source software is intended solely for educational purposes, specifica
本开源软件仅供学习和研究 LXC、KVM 等虚拟化技术原理之目的使用,不得用于任何违反适用法律法规、软件许可协议或第三方权益的行为。
本软件中涉及的 Windows 名称、标识、图标及相关知识产权均归 Microsoft Corporation 及其权利人所有。本项目与微软公司不存在任何关联、授权或合作关系。
## Thanks/鸣谢
## Thanks / 鸣谢
- [Nodeseek.com](https://www.nodeseek.com) — 一个专注于服务器的社区
- [Linux.do](https://linux.do) — 一个充满灵感的科技社区
## Star History
<a href="https://www.star-history.com/?repos=MengMengCode%2FCLICD&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=MengMengCode/CLICD&type=date&theme=dark&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" />
</picture>
</a>
[![MengMengCode/CLICD Star History](http://mengmeng.meteor-history.com/api/embed/MengMengCode/CLICD.svg?sig=YT8i1bxihL6_GcFAa0CWRbQb35-B0XXyh-ZAxIsmV0U&theme=light&style=xkcd&color=dd4528&background=ffffff&textColor=000000&width=900&height=600&lineWidth=3&showTitle=true&showLegend=true&showDots=false&v=3)](https://meteor-history.com)
+4 -6
View File
@@ -1,18 +1,16 @@
module clicd
go 1.24.0
toolchain go1.24.5
go 1.25.0
require (
github.com/golang-jwt/jwt/v5 v5.2.2
github.com/gorilla/websocket v1.5.3
golang.org/x/crypto v0.45.0
golang.org/x/term v0.37.0
golang.org/x/crypto v0.52.0
golang.org/x/term v0.43.0
)
require (
golang.org/x/sys v0.38.0
golang.org/x/sys v0.45.0
modernc.org/sqlite v1.29.10
)
+6 -6
View File
@@ -18,8 +18,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/exp v0.0.0-20231108232855-2478ac86f678 h1:mchzmB1XO2pMaKFRqk/+MV3mgGG96aqaPXaMifQU47w=
golang.org/x/exp v0.0.0-20231108232855-2478ac86f678/go.mod h1:zk2irFbV9DP96SEBUUAy67IdHUaZuSnrz1n472HUCLE=
golang.org/x/mod v0.19.0 h1:fEdghXQSo20giMthA7cd28ZC+jts4amQ3YMXiP5oMQ8=
@@ -27,10 +27,10 @@ golang.org/x/mod v0.19.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/tools v0.23.0 h1:SGsXPZ+2l4JsgaCKkx+FQ9YZ5XEtA1GZYuoDjenLjvg=
golang.org/x/tools v0.23.0/go.mod h1:pnu6ufv6vQkll6szChhK3C3L/ruaIv5eBeztNG8wtsI=
modernc.org/cc/v4 v4.24.4 h1:TFkx1s6dCkQpd6dKurBNmpo+G8Zl4Sq/ztJ+2+DEsh0=
+94
View File
@@ -0,0 +1,94 @@
package api
import (
"encoding/json"
"net/http"
"strings"
"clicd/internal/config"
)
type panelAccessPolicyResponse struct {
Enabled bool `json:"enabled"`
AllowedSources []string `json:"allowed_sources"`
TrustedProxies []string `json:"trusted_proxies"`
CurrentSource string `json:"current_source"`
DirectSource string `json:"direct_source"`
UsingForwarded bool `json:"using_forwarded"`
}
func HandlePanelAccessPolicy(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: panelAccessPolicyStatus(r, config.AppConfig.PanelAccessPolicy)})
case http.MethodPut:
updatePanelAccessPolicy(w, r)
default:
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
}
}
func updatePanelAccessPolicy(w http.ResponseWriter, r *http.Request) {
var requested config.PanelAccessPolicy
if err := json.NewDecoder(r.Body).Decode(&requested); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
return
}
normalized, err := config.NormalizePanelAccessPolicy(requested)
if err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
return
}
decision := evaluatePanelRequest(r, normalized)
if normalized.Enabled && !decision.Allowed {
jsonResponse(w, http.StatusBadRequest, APIResponse{
Success: false,
Message: "The new access policy does not allow your current source address " + decision.CurrentSource,
})
return
}
previous := config.AppConfig.PanelAccessPolicy
config.AppConfig.PanelAccessPolicy = normalized
if err := config.SaveConfig(); err != nil {
config.AppConfig.PanelAccessPolicy = previous
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to save panel access policy"})
return
}
detail := "enabled=" + strings.ToLower(strings.TrimSpace(boolText(normalized.Enabled))) +
",allowed=" + strings.Join(normalized.AllowedSources, ",") +
",trusted_proxies=" + strings.Join(normalized.TrustedProxies, ",")
auditRequest(r, "settings.panel_access", "Panel access policy", detail, true, "")
jsonResponse(w, http.StatusOK, APIResponse{
Success: true,
Message: "Panel access policy saved",
Data: panelAccessPolicyStatus(r, normalized),
})
}
func panelAccessPolicyStatus(r *http.Request, policy config.PanelAccessPolicy) panelAccessPolicyResponse {
decision := evaluatePanelRequest(r, policy)
return panelAccessPolicyResponse{
Enabled: policy.Enabled,
AllowedSources: append([]string(nil), policy.AllowedSources...),
TrustedProxies: append([]string(nil), policy.TrustedProxies...),
CurrentSource: decision.CurrentSource,
DirectSource: decision.DirectSource,
UsingForwarded: decision.UsedForwarded,
}
}
func evaluatePanelRequest(r *http.Request, policy config.PanelAccessPolicy) config.PanelAccessDecision {
return config.EvaluatePanelAccess(policy, r.RemoteAddr, config.ForwardedClientHeaders{
ForwardedFor: r.Header.Get("X-Forwarded-For"),
RealIP: r.Header.Get("X-Real-IP"),
CFConnectingIP: r.Header.Get("CF-Connecting-IP"),
})
}
func boolText(value bool) string {
if value {
return "true"
}
return "false"
}
+1 -1
View File
@@ -90,7 +90,7 @@ func hasScope(r *http.Request, scope string) bool {
func subUserScopeAllowed(scope string) bool {
switch scope {
case "container:read", "container:power", "container:reinstall", "container:network",
case "container:read", "container:power", "container:reinstall", "container:password", "container:network",
"dashboard:read", "image:read", "task:read", "snapshot:read", "snapshot:create", "snapshot:delete", "snapshot:restore", "snapshot:schedule",
"terminal:ssh", "terminal:vnc":
return true
+242
View File
@@ -0,0 +1,242 @@
package api
import (
"encoding/json"
"fmt"
"math"
"strconv"
"sync"
"time"
"clicd/internal/config"
)
type ContainerMetricPoint struct {
TS int64 `json:"ts"`
CPU float64 `json:"cpu"`
Memory float64 `json:"memory"`
Network float64 `json:"network"`
NetworkRx float64 `json:"network_rx"`
NetworkTx float64 `json:"network_tx"`
DiskIO float64 `json:"disk_io"`
DiskRead float64 `json:"disk_read"`
DiskWrite float64 `json:"disk_write"`
}
var containerMetricSamplerOnce sync.Once
var containerMetricMu sync.RWMutex
var containerMetricHistory = map[string][]ContainerMetricPoint{}
var containerMetricInFlight sync.Map
const (
containerMetricSampleInterval = 30 * time.Second
containerMetricSampleTimeout = 20 * time.Second
containerMetricConcurrency = 4
)
func StartContainerMetricSampler() {
containerMetricSamplerOnce.Do(func() {
go func() {
sampleAllContainerMetrics()
ticker := time.NewTicker(containerMetricSampleInterval)
defer ticker.Stop()
for range ticker.C {
sampleAllContainerMetrics()
}
}()
})
}
func sampleAllContainerMetrics() {
containers, _ := listByRuntime()
sem := make(chan struct{}, containerMetricConcurrency)
var wg sync.WaitGroup
for _, c := range containers {
c := c
if c.Status != "running" {
continue
}
sem <- struct{}{}
wg.Add(1)
go func() {
defer wg.Done()
defer func() { <-sem }()
sampleContainerMetricWithTimeout(c)
}()
}
wg.Wait()
pruneContainerMetricHistory()
}
func sampleContainerMetricWithTimeout(c config.Container) {
key := containerMetricKey(c)
if key == "" {
return
}
if _, loaded := containerMetricInFlight.LoadOrStore(key, struct{}{}); loaded {
return
}
done := make(chan struct{}, 1)
go func() {
defer containerMetricInFlight.Delete(key)
if usage, err := usageByRuntime(c.ID); err == nil {
appendContainerMetricPoint(c, usage)
}
done <- struct{}{}
}()
select {
case <-done:
case <-time.After(containerMetricSampleTimeout):
}
}
func appendContainerMetricPoint(c config.Container, usage map[string]interface{}) {
key := containerMetricKey(c)
if key == "" {
return
}
memoryTotal := numberFromUsage(usage, "memory_total_bytes")
if memoryTotal <= 0 {
memoryTotal = float64(c.RAMMB) * 1024 * 1024
}
memoryPct := 0.0
if memoryTotal > 0 {
memoryPct = clampPercent(numberFromUsage(usage, "memory_usage_bytes") / memoryTotal * 100)
}
vcpu := c.VCPU
if vcpu <= 0 {
vcpu = 1
}
cpuPct := clampPercent(numberFromUsage(usage, "cpu_usage_pct") / vcpu)
networkRx := positiveNumberFromUsage(usage, "network_rx_bps")
networkTx := positiveNumberFromUsage(usage, "network_tx_bps")
diskRead := positiveNumberFromUsage(usage, "disk_read_bps")
diskWrite := positiveNumberFromUsage(usage, "disk_write_bps")
point := ContainerMetricPoint{
TS: time.Now().UnixMilli(),
CPU: cpuPct,
Memory: memoryPct,
NetworkRx: networkRx,
NetworkTx: networkTx,
Network: networkRx + networkTx,
DiskRead: diskRead,
DiskWrite: diskWrite,
DiskIO: diskRead + diskWrite,
}
cutoff := time.Now().Add(-hostMetricRetention).UnixMilli()
containerMetricMu.Lock()
defer containerMetricMu.Unlock()
history := containerMetricHistory[key]
keepFrom := 0
for keepFrom < len(history) && history[keepFrom].TS < cutoff {
keepFrom++
}
if keepFrom > 0 {
copy(history, history[keepFrom:])
history = history[:len(history)-keepFrom]
}
containerMetricHistory[key] = append(history, point)
}
func getContainerMetricHistory(c *config.Container) []ContainerMetricPoint {
if c == nil {
return nil
}
key := containerMetricKey(*c)
containerMetricMu.RLock()
defer containerMetricMu.RUnlock()
history := containerMetricHistory[key]
result := make([]ContainerMetricPoint, len(history))
copy(result, history)
return result
}
func pruneContainerMetricHistory() {
cutoff := time.Now().Add(-hostMetricRetention).UnixMilli()
valid := map[string]bool{}
if config.AppConfig != nil {
for _, c := range config.AppConfig.Containers {
valid[containerMetricKey(c)] = true
}
}
containerMetricMu.Lock()
defer containerMetricMu.Unlock()
for key, history := range containerMetricHistory {
if !valid[key] {
delete(containerMetricHistory, key)
continue
}
keepFrom := 0
for keepFrom < len(history) && history[keepFrom].TS < cutoff {
keepFrom++
}
if keepFrom > 0 {
copy(history, history[keepFrom:])
containerMetricHistory[key] = history[:len(history)-keepFrom]
}
}
}
func containerMetricKey(c config.Container) string {
if c.UUID != "" {
return "uuid:" + c.UUID
}
if c.ID > 0 {
return fmt.Sprintf("id:%d", c.ID)
}
if c.Name != "" {
return "name:" + c.Name
}
return ""
}
func numberFromUsage(usage map[string]interface{}, key string) float64 {
value, ok := usage[key]
if !ok || value == nil {
return 0
}
switch v := value.(type) {
case float64:
if math.IsNaN(v) || math.IsInf(v, 0) {
return 0
}
return v
case float32:
return float64(v)
case int:
return float64(v)
case int64:
return float64(v)
case int32:
return float64(v)
case uint:
return float64(v)
case uint64:
return float64(v)
case uint32:
return float64(v)
case json.Number:
n, _ := v.Float64()
return n
case string:
n, _ := strconv.ParseFloat(v, 64)
return n
default:
return 0
}
}
func positiveNumberFromUsage(usage map[string]interface{}, key string) float64 {
value := numberFromUsage(usage, key)
if value < 0 {
return 0
}
return value
}
+257
View File
@@ -0,0 +1,257 @@
package api
import (
"encoding/json"
"math/rand"
"net/http"
"net/netip"
"strconv"
"strings"
"clicd/internal/config"
"clicd/internal/lxc"
)
func generateFirewallRuleID() string {
const chars = "abcdefghijklmnopqrstuvwxyz0123456789"
b := make([]byte, 8)
for i := range b {
b[i] = chars[rand.Intn(len(chars))]
}
return string(b)
}
func getFirewall(w http.ResponseWriter, r *http.Request, id int) {
c := config.FindContainer(id)
if c == nil {
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"})
return
}
jsonResponse(w, http.StatusOK, APIResponse{
Success: true,
Data: map[string]interface{}{
"enabled": c.FirewallEnabled,
"default_action": normalizeFirewallDefaultAction(c.FirewallDefaultAction),
"rules": c.FirewallRules,
},
})
}
func updateFirewall(w http.ResponseWriter, r *http.Request, id int) {
c := config.FindContainer(id)
if c == nil {
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"})
return
}
var req struct {
Enabled *bool `json:"enabled"`
DefaultAction *string `json:"default_action"`
Rules *[]config.FirewallRule `json:"rules"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
return
}
oldEnabled := c.FirewallEnabled
oldDefaultAction := c.FirewallDefaultAction
oldRules := append([]config.FirewallRule(nil), c.FirewallRules...)
if req.Enabled != nil {
c.FirewallEnabled = *req.Enabled
}
if req.DefaultAction != nil {
action := normalizeFirewallDefaultAction(*req.DefaultAction)
if action == "" {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid default action"})
return
}
c.FirewallDefaultAction = action
} else if strings.TrimSpace(c.FirewallDefaultAction) == "" {
c.FirewallDefaultAction = "DROP"
}
if req.Rules != nil {
// Validate and assign IDs to new rules
rules := *req.Rules
for i := range rules {
rules[i].Direction = strings.ToLower(strings.TrimSpace(rules[i].Direction))
rules[i].Protocol = strings.ToLower(strings.TrimSpace(rules[i].Protocol))
rules[i].Action = strings.ToUpper(strings.TrimSpace(rules[i].Action))
rules[i].Network = normalizeFirewallNetwork(rules[i].Network)
rules[i].SourceIP = strings.TrimSpace(rules[i].SourceIP)
rules[i].Port = strings.TrimSpace(rules[i].Port)
if rules[i].Network == "" {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid network"})
return
}
if rules[i].Direction != "in" && rules[i].Direction != "out" {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid direction: " + rules[i].Direction})
return
}
if rules[i].Protocol != "tcp" && rules[i].Protocol != "udp" && rules[i].Protocol != "icmp" && rules[i].Protocol != "all" {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid protocol: " + rules[i].Protocol})
return
}
if rules[i].Action != "ACCEPT" && rules[i].Action != "DROP" {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid action: " + rules[i].Action})
return
}
if rules[i].SourceIP != "" {
if err := validateFirewallIPSpec(rules[i].SourceIP, rules[i].Network); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid IP: " + err.Error()})
return
}
}
if rules[i].ID == "" || strings.HasPrefix(rules[i].ID, "tmp-") {
rules[i].ID = generateFirewallRuleID()
}
// Validate port spec
if rules[i].Port != "" {
if rules[i].Protocol != "tcp" && rules[i].Protocol != "udp" {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Ports are only supported for TCP and UDP rules"})
return
}
if err := validatePortSpec(rules[i].Port); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid port: " + err.Error()})
return
}
}
}
c.FirewallRules = rules
}
// Apply firewall rules to iptables if container is running
if c.Status == "running" {
if err := lxc.ApplyFirewallRules(id); err != nil {
c.FirewallEnabled = oldEnabled
c.FirewallDefaultAction = oldDefaultAction
c.FirewallRules = oldRules
_ = lxc.ApplyFirewallRules(id)
config.SaveConfig()
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to apply firewall rules: " + err.Error()})
return
}
} else if !c.FirewallEnabled {
// If disabled and not running, clean any lingering rules
lxc.CleanFirewallRules(id)
}
config.SaveConfig()
jsonResponse(w, http.StatusOK, APIResponse{
Success: true,
Message: "Firewall updated",
Data: map[string]interface{}{
"enabled": c.FirewallEnabled,
"default_action": normalizeFirewallDefaultAction(c.FirewallDefaultAction),
"rules": c.FirewallRules,
},
})
}
func normalizeFirewallDefaultAction(action string) string {
action = strings.ToUpper(strings.TrimSpace(action))
if action == "ACCEPT" || action == "DROP" {
return action
}
return ""
}
func normalizeFirewallNetwork(network string) string {
network = strings.ToLower(strings.TrimSpace(network))
switch network {
case "", "ipv4", "nat4":
return "ipv4"
case "ipv6":
return "ipv6"
case "all", "both":
return "all"
default:
return ""
}
}
func validatePortSpec(port string) error {
port = strings.TrimSpace(port)
if port == "" {
return nil
}
// Support: "22", "80,443", "8000-9000", "80,443,8000-9000"
partCount := 0
for _, part := range strings.Split(port, ",") {
part = strings.TrimSpace(part)
if part == "" {
return &portValidationError{port}
}
partCount++
if strings.Contains(part, "-") {
// Range
bounds := strings.SplitN(part, "-", 2)
lo, err := strconv.Atoi(strings.TrimSpace(bounds[0]))
if err != nil || lo < 1 || lo > 65535 {
return &portValidationError{part}
}
hi, err := strconv.Atoi(strings.TrimSpace(bounds[1]))
if err != nil || hi < 1 || hi > 65535 {
return &portValidationError{part}
}
if hi < lo {
return &portValidationError{part}
}
} else {
p, err := strconv.Atoi(part)
if err != nil || p < 1 || p > 65535 {
return &portValidationError{part}
}
}
}
if partCount > 15 {
return &portValidationError{"too many ports; maximum 15 items per rule"}
}
return nil
}
func validateFirewallIPSpec(value string, network string) error {
var addr netip.Addr
if strings.Contains(value, "/") {
prefix, err := netip.ParsePrefix(value)
if err != nil {
return err
}
addr = prefix.Addr()
} else {
parsed, err := netip.ParseAddr(value)
if err != nil {
return err
}
addr = parsed
}
switch network {
case "ipv4":
if !addr.Is4() {
return &ipValidationError{"IPv4 rule requires an IPv4 address or CIDR: " + value}
}
case "ipv6":
if !addr.Is6() || addr.Is4In6() {
return &ipValidationError{"IPv6 rule requires an IPv6 address or CIDR: " + value}
}
}
return nil
}
type ipValidationError struct {
value string
}
func (e *ipValidationError) Error() string {
return e.value
}
type portValidationError struct {
port string
}
func (e *portValidationError) Error() string {
return "invalid port value: " + e.port
}
+244 -57
View File
@@ -3,11 +3,11 @@ package api
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
"unicode"
"clicd/internal/config"
"clicd/internal/lxc"
@@ -132,6 +132,11 @@ func HandleSingleContainer(w http.ResponseWriter, r *http.Request) {
return
}
getUsage(w, r, id)
case action == "history" && r.Method == http.MethodGet:
if !requireScope(w, r, "container:read") {
return
}
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: getContainerMetricHistory(c)})
case action == "traffic" && r.Method == http.MethodGet:
if !requireScope(w, r, "container:read") {
return
@@ -167,6 +172,16 @@ func HandleSingleContainer(w http.ResponseWriter, r *http.Request) {
return
}
assignIPv6(w, r, id)
case action == "public-ipv4" && r.Method == http.MethodPut:
if !requireScope(w, r, "container:network") {
return
}
updatePublicIPv4(w, r, id)
case action == "ipv6-addresses" && r.Method == http.MethodPut:
if !requireScope(w, r, "ipv6:assign") {
return
}
updateIPv6Addresses(w, r, id)
case action == "snapshots" || strings.HasPrefix(action, "snapshots/"):
handleContainerSnapshots(w, r, id, action)
case action == "port-mappings" && r.Method == http.MethodPost:
@@ -184,6 +199,16 @@ func HandleSingleContainer(w http.ResponseWriter, r *http.Request) {
return
}
deletePortMapping(w, r, id, strings.TrimPrefix(action, "port-mappings/"))
case action == "firewall" && r.Method == http.MethodGet:
if !requireScope(w, r, "container:network") {
return
}
getFirewall(w, r, id)
case action == "firewall" && r.Method == http.MethodPut:
if !requireScope(w, r, "container:network") {
return
}
updateFirewall(w, r, id)
case r.Method == http.MethodGet:
if !requireScope(w, r, "container:read") {
return
@@ -202,10 +227,21 @@ func listContainers(w http.ResponseWriter, r *http.Request) {
func createContainer(w http.ResponseWriter, r *http.Request) {
var cfg lxc.ContainerConfig
if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil {
body, err := io.ReadAll(r.Body)
if err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
return
}
var fields map[string]json.RawMessage
if err := json.Unmarshal(body, &cfg); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
return
}
_ = json.Unmarshal(body, &fields)
if err := normalizeCreateResourceLimits(&cfg, fields); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
return
}
if cfg.Name == "" {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Container name is required"})
return
@@ -219,6 +255,12 @@ func createContainer(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Template is not enabled or downloaded"})
return
}
if ids, err := normalizeAllowedImageIDs(cfg.AllowedImageIDs); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
return
} else {
cfg.AllowedImageIDs = ids
}
if cfg.VCPU <= 0 {
cfg.VCPU = 1
}
@@ -228,13 +270,40 @@ func createContainer(w http.ResponseWriter, r *http.Request) {
if cfg.DiskGB < 1 {
cfg.DiskGB = 5
}
if cfg.PortMappingCount < 2 {
cfg.PortMappingCount = 2
if cfg.PortMappingCount < 0 {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Port mapping count cannot be negative"})
return
}
if err := cfg.NormalizeCreateNATMappings(); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
return
}
if err := lxc.ValidateCreateNATPortAvailability(cfg); err != nil {
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: err.Error()})
return
}
if cfg.PortMappingCount > 64 {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Port mapping count cannot exceed 64"})
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 {
cfg.SnapshotLimit = config.DefaultSnapshotLimit
}
@@ -242,6 +311,14 @@ func createContainer(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
return
}
if err := validateCreateStoragePool(&cfg); err != nil {
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: err.Error()})
return
}
if err := validateCreateSSHAuth(cfg); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
return
}
if cfg.ExpiresAt != "" {
expiresAt, ok := lxc.ParseExpiration(cfg.ExpiresAt)
if !ok {
@@ -349,10 +426,14 @@ func updateTrafficLimit(w http.ResponseWriter, r *http.Request, id int) {
func updateResourceLimit(w http.ResponseWriter, r *http.Request, id int) {
var req struct {
VCPU float64 `json:"vcpu"`
RAMMB int `json:"ram_mb"`
IOMBps int `json:"io_speed_mbps"`
BWMbps int `json:"network_bw_mbps"`
VCPU *float64 `json:"vcpu"`
RAMMB *int `json:"ram_mb"`
IOMBps *int `json:"io_speed_mbps"`
IOReadMBps *int `json:"io_read_mbps"`
IOWriteMBps *int `json:"io_write_mbps"`
BWMbps *int `json:"network_bw_mbps"`
NetworkDownMbps *int `json:"network_down_mbps"`
NetworkUpMbps *int `json:"network_up_mbps"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request"})
@@ -367,29 +448,42 @@ func updateResourceLimit(w http.ResponseWriter, r *http.Request, id int) {
// Update config
nextVCPU := c.VCPU
nextRAMMB := c.RAMMB
if req.VCPU > 0 {
nextVCPU = req.VCPU
if req.VCPU != nil {
nextVCPU = *req.VCPU
}
if req.RAMMB > 0 {
nextRAMMB = req.RAMMB
if req.RAMMB != nil {
nextRAMMB = *req.RAMMB
}
if err := validateRuntimeResourceRequest(c.Runtime(), nextVCPU, nextRAMMB, c.DiskGB); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
return
}
for name, value := range map[string]*int{
"network_bw_mbps": req.BWMbps,
"network_down_mbps": req.NetworkDownMbps,
"network_up_mbps": req.NetworkUpMbps,
"io_speed_mbps": req.IOMBps,
"io_read_mbps": req.IOReadMBps,
"io_write_mbps": req.IOWriteMBps,
} {
if err := rejectNegativeLimit(name, value); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
return
}
}
c.VCPU = nextVCPU
c.RAMMB = nextRAMMB
c.IOSpeedMBps = req.IOMBps
c.NetworkBWMbps = req.BWMbps
applyNetworkLimitPatch(c, req.BWMbps, req.NetworkDownMbps, req.NetworkUpMbps)
applyIOLimitPatch(c, req.IOMBps, req.IOReadMBps, req.IOWriteMBps)
config.NormalizeContainerResourceAliases(c)
config.SaveConfig()
// Re-apply resource limits to running container
if c.Status == "running" {
if err := applyLimitsByRuntime(c); err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
return
}
// Re-apply persisted/runtime limits. LXC also uses this path to migrate
// old managed config lines such as lxc.prlimit.nproc.
if err := applyLimitsByRuntime(c); err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
return
}
msg := "Resource limits updated"
@@ -399,30 +493,130 @@ func updateResourceLimit(w http.ResponseWriter, r *http.Request, id int) {
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: msg})
}
func normalizeCreateResourceLimits(cfg *lxc.ContainerConfig, fields map[string]json.RawMessage) error {
if cfg == nil {
return nil
}
if err := rejectNegativeCreateLimits(*cfg); err != nil {
return err
}
bwSet := hasJSONField(fields, "network_bw_mbps")
downSet := hasJSONField(fields, "network_down_mbps")
upSet := hasJSONField(fields, "network_up_mbps")
if bwSet {
if !downSet {
cfg.NetworkDownMbps = cfg.NetworkBWMbps
}
if !upSet {
cfg.NetworkUpMbps = cfg.NetworkBWMbps
}
}
ioSet := hasJSONField(fields, "io_speed_mbps")
readSet := hasJSONField(fields, "io_read_mbps")
writeSet := hasJSONField(fields, "io_write_mbps")
if ioSet {
if !readSet {
cfg.IOReadMBps = cfg.IOSpeedMBps
}
if !writeSet {
cfg.IOWriteMBps = cfg.IOSpeedMBps
}
}
cfg.NormalizeResourceAliases()
return nil
}
func rejectNegativeCreateLimits(cfg lxc.ContainerConfig) error {
for name, value := range map[string]int{
"network_bw_mbps": cfg.NetworkBWMbps,
"network_down_mbps": cfg.NetworkDownMbps,
"network_up_mbps": cfg.NetworkUpMbps,
"io_speed_mbps": cfg.IOSpeedMBps,
"io_read_mbps": cfg.IOReadMBps,
"io_write_mbps": cfg.IOWriteMBps,
} {
if value < 0 {
return fmt.Errorf("%s cannot be negative", name)
}
}
return nil
}
func hasJSONField(fields map[string]json.RawMessage, name string) bool {
if fields == nil {
return false
}
_, ok := fields[name]
return ok
}
func rejectNegativeLimit(name string, value *int) error {
if value != nil && *value < 0 {
return fmt.Errorf("%s cannot be negative", name)
}
return nil
}
func applyNetworkLimitPatch(c *config.Container, legacy *int, down *int, up *int) {
if c == nil {
return
}
config.NormalizeContainerResourceAliases(c)
nextDown := c.NetworkDownMbps
nextUp := c.NetworkUpMbps
if legacy != nil {
nextDown = *legacy
nextUp = *legacy
}
if down != nil {
nextDown = *down
}
if up != nil {
nextUp = *up
}
c.NetworkDownMbps = nextDown
c.NetworkUpMbps = nextUp
c.NetworkBWMbps = config.LegacySymmetricLimit(nextDown, nextUp)
}
func applyIOLimitPatch(c *config.Container, legacy *int, read *int, write *int) {
if c == nil {
return
}
config.NormalizeContainerResourceAliases(c)
nextRead := c.IOReadMBps
nextWrite := c.IOWriteMBps
if legacy != nil {
nextRead = *legacy
nextWrite = *legacy
}
if read != nil {
nextRead = *read
}
if write != nil {
nextWrite = *write
}
c.IOReadMBps = nextRead
c.IOWriteMBps = nextWrite
c.IOSpeedMBps = config.LegacySymmetricLimit(nextRead, nextWrite)
}
func getRandomPort(w http.ResponseWriter, r *http.Request, id int) {
c := config.FindContainer(id)
if c == nil {
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"})
return
}
// Find a random unused port between 10000-65535
used := map[int]bool{}
for _, pm := range c.PortMappings {
used[pm.HostPort] = true
hostIP := strings.TrimSpace(r.URL.Query().Get("host_ip"))
start, end := config.NATPortRange()
capacity := end - start + 1
offset := 0
if capacity > 0 {
offset = int(time.Now().UnixNano() % int64(capacity))
}
// 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
for tries := 0; tries < 100; tries++ {
port := 10000 + (int(time.Now().UnixNano()) % 55535)
if !used[port] {
for tries := 0; tries < capacity; tries++ {
port := start + ((offset + tries) % capacity)
if lxc.HostPortAvailable(c, hostIP, port, "tcp") {
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]int{"port": port}})
return
}
@@ -488,6 +682,18 @@ func HandleHostInfo(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: info})
}
// HandleHostHistory returns host resource samples collected by the server.
func HandleHostHistory(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
if !requireScope(w, r, "host:read") {
return
}
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: getHostMetricHistory()})
}
func resetSSHPassword(w http.ResponseWriter, r *http.Request, id int) {
c := config.FindContainer(id)
if c != nil && lxc.IsExpired(*c) {
@@ -524,26 +730,7 @@ func resetSSHPassword(w http.ResponseWriter, r *http.Request, id int) {
}
func validateSSHPassword(password string) error {
if len(password) < 8 || len(password) > 64 {
return fmt.Errorf("密码长度必须为 8-64 位")
}
hasLetter := false
hasDigit := false
for _, r := range password {
if unicode.IsSpace(r) {
return fmt.Errorf("密码不能包含空白字符")
}
if unicode.IsLetter(r) {
hasLetter = true
}
if unicode.IsDigit(r) {
hasDigit = true
}
}
if !hasLetter || !hasDigit {
return fmt.Errorf("密码至少需要包含字母和数字")
}
return nil
return lxc.ValidateCustomSSHPassword(password)
}
func addPortMapping(w http.ResponseWriter, r *http.Request, id int) {
+542 -36
View File
@@ -5,6 +5,8 @@ import (
"context"
"encoding/json"
"fmt"
"io"
"math"
"net"
"net/http"
"os"
@@ -21,12 +23,13 @@ import (
)
type HostInfo struct {
CPU CpuInfo `json:"cpu"`
RAM MemoryInfo `json:"ram"`
Disk DiskInfo `json:"disk"`
Network NetworkInfo `json:"network"`
DiskIO DiskIOInfo `json:"disk_io"`
Load LoadInfo `json:"load"`
CPU CpuInfo `json:"cpu"`
RAM MemoryInfo `json:"ram"`
Disk DiskInfo `json:"disk"`
Network NetworkInfo `json:"network"`
DiskIO DiskIOInfo `json:"disk_io"`
Load LoadInfo `json:"load"`
Runtime HostRuntimeProbe `json:"runtime"`
}
type HostProbeReport struct {
@@ -85,6 +88,7 @@ type HostDiskProbe struct {
Serial string `json:"serial"`
SizeBytes uint64 `json:"size_bytes"`
Type string `json:"type"`
Virtual bool `json:"virtual"`
Rotational bool `json:"rotational"`
Mountpoints []string `json:"mountpoints"`
Health string `json:"health"`
@@ -201,6 +205,7 @@ type NetworkInfo struct {
TXBps float64 `json:"tx_bps"`
PublicIPv4 string `json:"public_ipv4"`
PublicIPv4Interface string `json:"public_ipv4_interface"`
PublicIPv4Addresses []lxc.PublicIPInfo `json:"public_ipv4_addresses"`
PublicIPv6 string `json:"public_ipv6"`
PublicIPv6Interface string `json:"public_ipv6_interface"`
IPv6Prefixes []lxc.IPv6PrefixInfo `json:"ipv6_prefixes"`
@@ -213,10 +218,34 @@ type DiskIOInfo struct {
WriteBps float64 `json:"write_bps"`
}
type HostMetricPoint struct {
TS int64 `json:"ts"`
CPU float64 `json:"cpu"`
Memory float64 `json:"memory"`
Network float64 `json:"network"`
NetworkRx float64 `json:"network_rx"`
NetworkTx float64 `json:"network_tx"`
DiskIO float64 `json:"disk_io"`
DiskRead float64 `json:"disk_read"`
DiskWrite float64 `json:"disk_write"`
DiskUsagePct float64 `json:"disk_usage_pct"`
}
var hostCPUMu sync.Mutex
var lastHostCPU cpuTimes
var hostIOMu sync.Mutex
var lastHostIO hostIOSample
var hostMetricSamplerOnce sync.Once
var hostMetricMu sync.RWMutex
var hostMetricHistory []HostMetricPoint
var egressIPv4Mu sync.Mutex
var cachedEgressIPv4 lxc.PublicIPInfo
var cachedEgressIPv4At time.Time
const (
hostMetricSampleInterval = 30 * time.Second
hostMetricRetention = 7 * 24 * time.Hour
)
type cpuTimes struct {
Total uint64
@@ -232,6 +261,10 @@ type hostIOSample struct {
}
func getHostInfo() HostInfo {
return getHostInfoWithNetworkDetails(true)
}
func getHostInfoWithNetworkDetails(includeDetails bool) HostInfo {
info := HostInfo{
CPU: CpuInfo{Cores: runtime.NumCPU()},
}
@@ -239,11 +272,107 @@ func getHostInfo() HostInfo {
info.RAM = getMemoryInfo()
info.Disk = getDiskInfo()
info.CPU.Usage = getCPUUsage()
info.Network, info.DiskIO = getHostRates()
info.Network, info.DiskIO = getHostRates(includeDetails)
info.Load = getLoadInfo()
info.Runtime = detectRuntimeProbeQuick()
return info
}
func detectRuntimeProbeQuick() HostRuntimeProbe {
devKVM := fileExists("/dev/kvm")
nested, detail := detectNestedVirtualization()
lxcOK := commandExists("lxc-create")
kvmSupportedArch := runtime.GOARCH == "amd64" || runtime.GOARCH == "arm64"
kvmOK := kvmSupportedArch && devKVM && commandExists("virsh") && commandExists(kvmQEMUCheckKey())
probe := HostRuntimeProbe{
LXCAvailable: lxcOK,
KVMAvailable: kvmOK,
DevKVM: devKVM,
NestedVirtualization: nested,
NestedDetail: detail,
SupportMode: "unsupported",
}
if probe.KVMAvailable {
probe.SupportMode = "kvm_lxc"
} else if probe.LXCAvailable {
probe.SupportMode = "lxc_only"
}
return probe
}
func StartHostMetricSampler() {
hostMetricSamplerOnce.Do(func() {
appendHostMetricPoint(getHostInfoWithNetworkDetails(false))
go func() {
ticker := time.NewTicker(hostMetricSampleInterval)
defer ticker.Stop()
for range ticker.C {
appendHostMetricPoint(getHostInfoWithNetworkDetails(false))
}
}()
})
}
func appendHostMetricPoint(info HostInfo) {
memoryPct := 0.0
if info.RAM.TotalMB > 0 {
memoryPct = clampPercent(float64(info.RAM.UsedMB) / float64(info.RAM.TotalMB) * 100)
}
diskUsagePct := 0.0
if info.Disk.TotalGB > 0 {
diskUsagePct = clampPercent(info.Disk.UsedGB / info.Disk.TotalGB * 100)
}
point := HostMetricPoint{
TS: time.Now().UnixMilli(),
CPU: clampPercent(info.CPU.Usage),
Memory: memoryPct,
NetworkRx: info.Network.RXBps,
NetworkTx: info.Network.TXBps,
Network: info.Network.RXBps + info.Network.TXBps,
DiskRead: info.DiskIO.ReadBps,
DiskWrite: info.DiskIO.WriteBps,
DiskIO: info.DiskIO.ReadBps + info.DiskIO.WriteBps,
DiskUsagePct: diskUsagePct,
}
cutoff := time.Now().Add(-hostMetricRetention).UnixMilli()
hostMetricMu.Lock()
defer hostMetricMu.Unlock()
keepFrom := 0
for keepFrom < len(hostMetricHistory) && hostMetricHistory[keepFrom].TS < cutoff {
keepFrom++
}
if keepFrom > 0 {
copy(hostMetricHistory, hostMetricHistory[keepFrom:])
hostMetricHistory = hostMetricHistory[:len(hostMetricHistory)-keepFrom]
}
hostMetricHistory = append(hostMetricHistory, point)
}
func getHostMetricHistory() []HostMetricPoint {
hostMetricMu.RLock()
defer hostMetricMu.RUnlock()
result := make([]HostMetricPoint, len(hostMetricHistory))
copy(result, hostMetricHistory)
return result
}
func clampPercent(value float64) float64 {
if value < 0 || !isFiniteFloat(value) {
return 0
}
if value > 100 {
return 100
}
return value
}
func isFiniteFloat(value float64) bool {
return !math.IsNaN(value) && !math.IsInf(value, 0)
}
func getMemoryInfo() MemoryInfo {
f, err := os.Open("/proc/meminfo")
if err != nil {
@@ -389,19 +518,22 @@ func parseSizeGBf(s string) (float64, error) {
return val, err
}
func getHostRates() (NetworkInfo, DiskIOInfo) {
func getHostRates(includeDetails bool) (NetworkInfo, DiskIOInfo) {
rx, tx := readHostNetworkBytes()
readBytes, writeBytes := readHostDiskBytes()
now := unixNano()
network := NetworkInfo{RXBytes: rx, TXBytes: tx}
publicIPv4 := lxc.DetectPublicIPv4()
network.PublicIPv4 = publicIPv4.Address
network.PublicIPv4Interface = publicIPv4.Interface
network.IPv6Prefixes = lxc.DetectPublicIPv6Prefixes()
if len(network.IPv6Prefixes) > 0 {
network.PublicIPv6 = network.IPv6Prefixes[0].Address
network.PublicIPv6Interface = network.IPv6Prefixes[0].Interface
if includeDetails {
publicIPv4 := detectDisplayPublicIPv4()
network.PublicIPv4 = publicIPv4.Address
network.PublicIPv4Interface = publicIPv4.Interface
network.PublicIPv4Addresses = lxc.DetectFreePublicIPv4Candidates(0)
network.IPv6Prefixes = lxc.DetectHostPublicIPv6Prefixes()
if len(network.IPv6Prefixes) > 0 {
network.PublicIPv6 = network.IPv6Prefixes[0].Address
network.PublicIPv6Interface = network.IPv6Prefixes[0].Interface
}
}
diskIO := DiskIOInfo{ReadBytes: readBytes, WriteBytes: writeBytes}
@@ -434,23 +566,79 @@ func getHostRates() (NetworkInfo, DiskIOInfo) {
}
func readHostNetworkBytes() (uint64, uint64) {
entries, err := os.ReadDir("/sys/class/net")
if err != nil {
return 0, 0
ifaces := detectHostTrafficInterfaces()
if len(ifaces) == 0 {
ifaces = fallbackHostTrafficInterfaces()
}
var rx, tx uint64
for _, entry := range entries {
name := entry.Name()
if name == "lo" {
continue
}
for _, name := range ifaces {
rx += readUintFile("/sys/class/net/" + name + "/statistics/rx_bytes")
tx += readUintFile("/sys/class/net/" + name + "/statistics/tx_bytes")
}
return rx, tx
}
func detectHostTrafficInterfaces() []string {
seen := map[string]bool{}
result := make([]string, 0, 2)
add := func(name string) {
name = strings.TrimSpace(name)
if !isHostTrafficInterface(name) || seen[name] {
return
}
seen[name] = true
result = append(result, name)
}
if iface, _ := detectDefaultIPv4Route(); iface != "" {
add(iface)
}
if iface, _ := detectDefaultIPv6Route(); iface != "" {
add(iface)
}
if pub := lxc.DetectPublicIPv4(); pub.Interface != "" {
add(pub.Interface)
}
for _, prefix := range lxc.DetectHostPublicIPv6Prefixes() {
add(prefix.Interface)
}
return result
}
func fallbackHostTrafficInterfaces() []string {
entries, err := os.ReadDir("/sys/class/net")
if err != nil {
return nil
}
result := make([]string, 0)
for _, entry := range entries {
name := entry.Name()
if !isHostTrafficInterface(name) {
continue
}
state := strings.TrimSpace(readFirstExistingFile(filepath.Join("/sys/class/net", name, "operstate")))
if state == "down" {
continue
}
result = append(result, name)
}
sort.Strings(result)
return result
}
func isHostTrafficInterface(name string) bool {
name = strings.TrimSpace(name)
if name == "" || name == "lo" {
return false
}
if isContainerLikeInterfaceName(name) {
return false
}
return true
}
func readHostDiskBytes() (uint64, uint64) {
f, err := os.Open("/proc/diskstats")
if err != nil {
@@ -540,7 +728,7 @@ func getHostProbeReport() HostProbeReport {
Disks: detectHostDisks(),
NetworkInterfaces: detectHostNICs(),
PublicIPv4: detectAllPublicIPv4(),
IPv6Prefixes: lxc.DetectPublicIPv6Prefixes(),
IPv6Prefixes: lxc.DetectHostPublicIPv6Prefixes(),
Gateways: detectGateways(),
GPUs: detectGPUs(),
System: detectSystemProbe(),
@@ -571,6 +759,8 @@ func trimOSReleaseValue(value string) string {
func detectHostCPUProbe() HostCPUProbe {
probe := HostCPUProbe{Cores: runtime.NumCPU(), Threads: runtime.NumCPU(), Architecture: runtime.GOARCH}
armImplementer := ""
armPart := ""
if data, err := os.ReadFile("/proc/cpuinfo"); err == nil {
seenFlags := map[string]bool{}
for _, line := range strings.Split(string(data), "\n") {
@@ -579,19 +769,28 @@ func detectHostCPUProbe() HostCPUProbe {
continue
}
key := strings.TrimSpace(fields[0])
keyLower := strings.ToLower(key)
value := strings.TrimSpace(fields[1])
switch key {
case "model name", "Hardware", "Processor":
if probe.Model == "" {
switch keyLower {
case "model name", "hardware", "processor":
if probe.Model == "" && meaningfulCPUModel(value) {
probe.Model = value
}
case "cpu cores":
if cores, err := strconv.Atoi(value); err == nil && cores > probe.Cores {
probe.Cores = cores
}
case "flags", "Features":
case "cpu implementer":
if armImplementer == "" {
armImplementer = strings.ToLower(value)
}
case "cpu part":
if armPart == "" {
armPart = strings.ToLower(value)
}
case "flags", "features":
for _, flag := range strings.Fields(value) {
if flag == "vmx" || flag == "svm" {
if flag == "vmx" || flag == "svm" || flag == "virt" {
probe.Virtualization = true
probe.VirtualizationKey = flag
}
@@ -604,12 +803,132 @@ func detectHostCPUProbe() HostCPUProbe {
}
sort.Strings(probe.Flags)
}
enrichCPUProbeFromLscpu(&probe, &armImplementer, &armPart)
if probe.Model == "" {
probe.Model = armCPUModelName(armImplementer, armPart)
}
if probe.Model == "" && runtime.GOARCH == "arm64" {
probe.Model = "ARM64 CPU"
}
if probe.Model == "" {
probe.Model = "Unknown"
}
return probe
}
func meaningfulCPUModel(value string) bool {
value = strings.TrimSpace(value)
if value == "" {
return false
}
if _, err := strconv.Atoi(value); err == nil {
return false
}
lower := strings.ToLower(value)
return lower != "unknown" && lower != "not specified"
}
func enrichCPUProbeFromLscpu(probe *HostCPUProbe, armImplementer *string, armPart *string) {
out := runCommandOutput(2*time.Second, "lscpu")
if out == "" {
return
}
for _, line := range strings.Split(out, "\n") {
fields := strings.SplitN(line, ":", 2)
if len(fields) != 2 {
continue
}
key := strings.ToLower(strings.TrimSpace(fields[0]))
value := strings.TrimSpace(fields[1])
switch key {
case "model name":
if probe.Model == "" && meaningfulCPUModel(value) {
probe.Model = value
}
case "cpu(s)":
if threads, err := strconv.Atoi(value); err == nil && threads > probe.Threads {
probe.Threads = threads
}
case "core(s) per socket":
if cores, err := strconv.Atoi(value); err == nil && cores > 0 {
probe.Cores = cores
}
case "socket(s)":
if sockets, err := strconv.Atoi(value); err == nil && sockets > 1 && probe.Cores > 0 {
probe.Cores *= sockets
}
case "virtualization":
lower := strings.ToLower(value)
if value != "" && lower != "none" && lower != "n/a" {
probe.Virtualization = true
probe.VirtualizationKey = value
}
case "flags":
seen := map[string]bool{}
for _, flag := range probe.Flags {
seen[flag] = true
}
for _, flag := range strings.Fields(value) {
if flag == "vmx" || flag == "svm" || flag == "virt" {
probe.Virtualization = true
probe.VirtualizationKey = flag
}
if !seen[flag] {
probe.Flags = append(probe.Flags, flag)
seen[flag] = true
}
}
sort.Strings(probe.Flags)
case "cpu implementer":
if *armImplementer == "" {
*armImplementer = strings.ToLower(value)
}
case "cpu part":
if *armPart == "" {
*armPart = strings.ToLower(value)
}
}
}
}
func armCPUModelName(implementer, part string) string {
implementer = normalizeHexID(implementer)
part = normalizeHexID(part)
if implementer == "" || part == "" {
return ""
}
armParts := map[string]string{
"0x41:0xd03": "ARM Cortex-A53",
"0x41:0xd05": "ARM Cortex-A55",
"0x41:0xd07": "ARM Cortex-A57",
"0x41:0xd08": "ARM Cortex-A72",
"0x41:0xd09": "ARM Cortex-A73",
"0x41:0xd0a": "ARM Cortex-A75",
"0x41:0xd0b": "ARM Cortex-A76",
"0x41:0xd0c": "ARM Neoverse N1",
"0x41:0xd0d": "ARM Cortex-A77",
"0x41:0xd40": "ARM Neoverse V1",
"0x41:0xd41": "ARM Cortex-A78",
"0x41:0xd49": "ARM Neoverse N2",
"0x41:0xd4f": "ARM Neoverse V2",
}
if model := armParts[implementer+":"+part]; model != "" {
return model
}
return strings.ToUpper(strings.TrimPrefix(implementer, "0x")) + " ARM CPU part " + part
}
func normalizeHexID(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
if value == "" {
return ""
}
if strings.HasPrefix(value, "0x") {
return value
}
return "0x" + value
}
func detectMemoryModules() []HostMemoryModule {
if !commandExists("dmidecode") {
return nil
@@ -676,17 +995,23 @@ func detectHostDisks() []HostDiskProbe {
}
base := filepath.Join("/sys/block", name)
path := "/dev/" + name
model := strings.TrimSpace(readFirstExistingFile(filepath.Join(base, "device/model"), filepath.Join(base, "device/name")))
vendor := strings.TrimSpace(readFirstExistingFile(filepath.Join(base, "device/vendor")))
virtual := isVirtualBlockDevice(name, model, vendor)
disk := HostDiskProbe{
Name: name,
Path: path,
Model: strings.TrimSpace(readFirstExistingFile(filepath.Join(base, "device/model"), filepath.Join(base, "device/name"))),
Model: model,
Serial: strings.TrimSpace(readFirstExistingFile(filepath.Join(base, "device/serial"), filepath.Join(base, "serial"))),
SizeBytes: readUintFile(filepath.Join(base, "size")) * 512,
Type: detectDiskType(base, name),
Type: detectDiskType(base, name, virtual),
Virtual: virtual,
Rotational: strings.TrimSpace(readFirstExistingFile(filepath.Join(base, "queue/rotational"))) == "1",
Mountpoints: mounts[name],
}
disk.SMART = detectDiskSMART(path)
if !virtual {
disk.SMART = detectDiskSMART(path)
}
disk.Health = disk.SMARTHealth()
disk.HealthDetail = disk.SMARTDetail()
disks = append(disks, disk)
@@ -695,7 +1020,10 @@ func detectHostDisks() []HostDiskProbe {
return disks
}
func detectDiskType(base, name string) string {
func detectDiskType(base, name string, virtual bool) string {
if virtual {
return "Virtual"
}
if strings.HasPrefix(name, "nvme") {
return "NVMe"
}
@@ -705,7 +1033,26 @@ func detectDiskType(base, name string) string {
return "SSD"
}
func isVirtualBlockDevice(name, model, vendor string) bool {
lower := strings.ToLower(strings.TrimSpace(name + " " + model + " " + vendor))
if strings.HasPrefix(name, "vd") || strings.HasPrefix(name, "xvd") {
return true
}
for _, token := range []string{
"qemu", "virtio", "virtual", "vmware", "vbox", "xen",
"amazon elastic block store", "google persistentdisk", "microsoft", "blockvolume",
} {
if strings.Contains(lower, token) {
return true
}
}
return false
}
func (disk HostDiskProbe) SMARTHealth() string {
if disk.Virtual {
return "virtual"
}
if disk.SMART.Available && disk.Health != "" {
return disk.Health
}
@@ -713,6 +1060,9 @@ func (disk HostDiskProbe) SMARTHealth() string {
}
func (disk HostDiskProbe) SMARTDetail() string {
if disk.Virtual {
return "虚拟磁盘,真实 SMART/寿命/通电数据需在物理宿主机查看"
}
return disk.SMART.Detail()
}
@@ -1119,9 +1469,126 @@ func detectAllPublicIPv4() []string {
result = append(result, value)
}
}
if egress := detectEgressPublicIPv4(); egress.Address != "" {
if !seen[egress.Address] {
seen[egress.Address] = true
result = append(result, egress.Address)
}
}
return result
}
func detectDisplayPublicIPv4() lxc.PublicIPInfo {
if pub := lxc.DetectPublicIPv4(); pub.Address != "" {
return pub
}
return detectEgressPublicIPv4()
}
func detectEgressPublicIPv4() lxc.PublicIPInfo {
egressIPv4Mu.Lock()
defer egressIPv4Mu.Unlock()
if cachedEgressIPv4.Address != "" && time.Since(cachedEgressIPv4At) < 5*time.Minute {
return cachedEgressIPv4
}
client := &http.Client{Timeout: 1200 * time.Millisecond}
for _, endpoint := range []string{
"https://api.ipify.org",
"https://ifconfig.me/ip",
"https://icanhazip.com",
} {
ctx, cancel := context.WithTimeout(context.Background(), 1200*time.Millisecond)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
cancel()
continue
}
resp, err := client.Do(req)
if err != nil {
cancel()
continue
}
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 128))
_ = resp.Body.Close()
cancel()
if readErr != nil || resp.StatusCode < 200 || resp.StatusCode >= 300 {
continue
}
address := strings.TrimSpace(string(body))
ip := net.ParseIP(address)
if !isPublicIPv4(ip) {
continue
}
iface, gateway := detectDefaultIPv4Route()
cachedEgressIPv4 = lxc.PublicIPInfo{
Address: ip.String(),
Interface: iface,
Prefix: ip.String() + "/32",
PrefixLen: 32,
SubnetMask: "255.255.255.255",
Gateway: gateway,
IsTunnel: isTunnelLikeInterfaceName(iface),
Source: "egress",
}
cachedEgressIPv4At = time.Now()
return cachedEgressIPv4
}
cachedEgressIPv4 = lxc.PublicIPInfo{}
cachedEgressIPv4At = time.Now()
return cachedEgressIPv4
}
func detectDefaultIPv4Route() (string, string) {
out := runCommandOutput(2*time.Second, "ip", "-4", "route", "show", "default")
for _, line := range strings.Split(out, "\n") {
fields := strings.Fields(line)
if len(fields) == 0 {
continue
}
iface := ""
gateway := ""
for i, field := range fields {
if field == "dev" && i+1 < len(fields) {
iface = fields[i+1]
}
if field == "via" && i+1 < len(fields) {
gateway = fields[i+1]
}
}
if iface != "" || gateway != "" {
return iface, gateway
}
}
return "", ""
}
func detectDefaultIPv6Route() (string, string) {
out := runCommandOutput(2*time.Second, "ip", "-6", "route", "show", "default")
for _, line := range strings.Split(out, "\n") {
fields := strings.Fields(line)
if len(fields) == 0 {
continue
}
iface := ""
gateway := ""
for i, field := range fields {
if field == "dev" && i+1 < len(fields) {
iface = fields[i+1]
}
if field == "via" && i+1 < len(fields) {
gateway = fields[i+1]
}
}
if iface != "" || gateway != "" {
return iface, gateway
}
}
return "", ""
}
func collectIPv4Addresses(nics []HostNICProbe) []HostIPProbe {
result := make([]HostIPProbe, 0)
for _, nic := range nics {
@@ -1267,6 +1734,16 @@ func isContainerLikeInterfaceName(iface string) bool {
return false
}
func isTunnelLikeInterfaceName(iface string) bool {
lower := strings.ToLower(strings.TrimSpace(iface))
for _, prefix := range []string{"tun", "tap", "wg", "gre", "gretap", "sit", "ip6tnl", "he-", "zt", "tailscale"} {
if lower == prefix || strings.HasPrefix(lower, prefix) {
return true
}
}
return false
}
func collectIPv6Addresses(nics []HostNICProbe) []HostIPProbe {
result := make([]HostIPProbe, 0)
for _, nic := range nics {
@@ -1334,6 +1811,8 @@ func detectGPUVendor(value string) string {
return "NVIDIA"
case strings.Contains(lower, "amd") || strings.Contains(lower, "ati"):
return "AMD"
case strings.Contains(lower, "virtio") || strings.Contains(lower, "red hat") || strings.Contains(lower, "qemu"):
return "Virtio"
default:
return "Unknown"
}
@@ -1341,6 +1820,9 @@ func detectGPUVendor(value string) string {
func detectGPUType(value string) string {
lower := strings.ToLower(value)
if strings.Contains(lower, "virtio") || strings.Contains(lower, "red hat") || strings.Contains(lower, "qemu") {
return "virtual"
}
if strings.Contains(lower, "intel") {
return "integrated"
}
@@ -1360,9 +1842,10 @@ func detectRuntimeProbe(env []HostEnvCheck) HostRuntimeProbe {
devKVM := fileExists("/dev/kvm")
nested, detail := detectNestedVirtualization()
lxcOK := envCheckOK(env, "lxc-create")
kvmSupportedArch := runtime.GOARCH == "amd64" || runtime.GOARCH == "arm64"
probe := HostRuntimeProbe{
LXCAvailable: lxcOK,
KVMAvailable: devKVM && envCheckOK(env, "virsh"),
KVMAvailable: kvmSupportedArch && devKVM && envCheckOK(env, "virsh") && envCheckOK(env, kvmQEMUCheckKey()),
DevKVM: devKVM,
NestedVirtualization: nested,
NestedDetail: detail,
@@ -1412,6 +1895,7 @@ func detectSystemProbe() HostSystemProbe {
}
func detectHostEnvironment() []HostEnvCheck {
qemuCheck := commandCheck(kvmQEMUCheckKey(), "QEMU/KVM 虚拟机", false, kvmQEMUCommand(), "")
checks := []HostEnvCheck{
commandCheck("service-manager", "服务管理器 systemd/OpenRC", true, "systemctl", "systemd"),
commandCheck("lxc-create", "LXC 创建工具", true, "lxc-create", ""),
@@ -1420,7 +1904,7 @@ func detectHostEnvironment() []HostEnvCheck {
commandCheck("ip", "iproute2 网络工具", true, "ip", ""),
commandCheck("conntrack", "conntrack 安全扫描", false, "conntrack", ""),
commandCheck("virsh", "libvirt virsh", false, "virsh", ""),
commandCheck("qemu-system-x86_64", "QEMU/KVM 虚拟机", false, "qemu-system-x86_64", ""),
qemuCheck,
commandCheck("genisoimage", "KVM cloud-init ISO 工具", false, "genisoimage", "xorriso/mkisofs 可替代"),
commandCheck("xorriso", "ISO 备用工具", false, "xorriso", ""),
commandCheck("smartctl", "硬盘健康检测", false, "smartctl", ""),
@@ -1433,11 +1917,24 @@ func detectHostEnvironment() []HostEnvCheck {
return checks
}
func kvmQEMUCheckKey() string {
switch runtime.GOARCH {
case "arm64":
return "qemu-system-aarch64"
default:
return "qemu-system-x86_64"
}
}
func kvmQEMUCommand() string {
return kvmQEMUCheckKey()
}
func commandCheck(key, label string, required bool, cmd string, fallback string) HostEnvCheck {
ok := commandExists(cmd)
detail := "missing"
if ok {
detail = strings.TrimSpace(runCommandOutput(2*time.Second, "sh", "-c", cmd+" --version 2>&1 | head -n 1"))
detail = commandVersionDetail(cmd)
if detail == "" {
detail = "installed"
}
@@ -1447,6 +1944,15 @@ func commandCheck(key, label string, required bool, cmd string, fallback string)
return HostEnvCheck{Key: key, Label: label, OK: ok, Required: required, Detail: detail}
}
func commandVersionDetail(cmd string) string {
switch cmd {
case "ip":
return strings.TrimSpace(runCommandOutput(2*time.Second, "sh", "-c", "ip -V 2>&1 | head -n 1"))
default:
return strings.TrimSpace(runCommandOutput(2*time.Second, "sh", "-c", cmd+" --version 2>&1 | head -n 1"))
}
}
func certbotCheck() HostEnvCheck {
check := HostEnvCheck{Key: "certbot", Label: "Certbot 证书工具 >= 5.4", Required: false, Detail: "missing"}
if !commandExists("certbot") {
+34
View File
@@ -41,3 +41,37 @@ func TestCertbotVersionAtLeast54(t *testing.T) {
}
}
}
func TestARMCPUModelName(t *testing.T) {
if got := armCPUModelName("0x41", "0xd0c"); got != "ARM Neoverse N1" {
t.Fatalf("armCPUModelName() = %q, want ARM Neoverse N1", got)
}
if got := armCPUModelName("41", "d0c"); got != "ARM Neoverse N1" {
t.Fatalf("armCPUModelName() without hex prefix = %q, want ARM Neoverse N1", got)
}
}
func TestMeaningfulCPUModel(t *testing.T) {
if meaningfulCPUModel("0") {
t.Fatal("numeric ARM processor index should not be treated as a CPU model")
}
if !meaningfulCPUModel("Neoverse-N1") {
t.Fatal("expected Neoverse-N1 to be treated as a CPU model")
}
}
func TestHostTrafficInterfaceFilter(t *testing.T) {
accepted := []string{"eth0", "ens3", "enp0s6", "bond0", "wg0"}
for _, name := range accepted {
if !isHostTrafficInterface(name) {
t.Fatalf("expected %s to be accepted as a host traffic interface", name)
}
}
rejected := []string{"", "lo", "docker0", "br-3024b78640ee", "lxcbr0", "virbr0", "vethaaa9e44", "cni0"}
for _, name := range rejected {
if isHostTrafficInterface(name) {
t.Fatalf("expected %s to be rejected as an internal/container interface", name)
}
}
}
+653 -43
View File
@@ -2,18 +2,24 @@ package api
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"os"
"os/exec"
"path/filepath"
"regexp"
"runtime"
"strings"
"sync"
"time"
"clicd/internal/config"
"clicd/internal/kvm"
"clicd/internal/lxc"
"clicd/internal/safehttp"
)
// ImageInfo represents a template image with its download/enable status.
@@ -36,10 +42,19 @@ type ImageInfo struct {
SizeBytes int64 `json:"size_bytes"`
ManualPath string `json:"manual_path,omitempty"`
Desktop string `json:"desktop,omitempty"`
Provisioner string `json:"provisioner,omitempty"`
Custom bool `json:"custom,omitempty"`
SHA256 string `json:"sha256,omitempty"`
}
var customImageFieldPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,63}$`)
var sha256Pattern = regexp.MustCompile(`^[a-fA-F0-9]{64}$`)
var imageDownloadsMu sync.Mutex
var imageDownloads = map[string]*imageDownloadStatus{}
var lxcImageCacheMu sync.Mutex
var lxcImageDownloadMu sync.Mutex
var lxcImageDownloadActive bool
type imageDownloadStatus struct {
Downloading bool
@@ -135,6 +150,22 @@ func isImageDownloadActive(id string) bool {
return st != nil && st.Downloading
}
func beginLXCImageDownload() bool {
lxcImageDownloadMu.Lock()
defer lxcImageDownloadMu.Unlock()
if lxcImageDownloadActive {
return false
}
lxcImageDownloadActive = true
return true
}
func endLXCImageDownload() {
lxcImageDownloadMu.Lock()
lxcImageDownloadActive = false
lxcImageDownloadMu.Unlock()
}
func lxcImageDownloadTempName(id string) string {
return fmt.Sprintf("clicd-img-dl-%s", id)
}
@@ -156,46 +187,68 @@ func cleanupOldImageDownloadErrors() {
}
}
// isImageDownloaded checks if the LXC download cache exists for a template.
func isImageDownloaded(distro, release, arch string) bool {
downloaded, _ := imageDownloadedInfo(distro, release, arch)
return downloaded
}
// imageDownloadedInfo returns whether the image is downloaded and its total size in bytes.
func imageDownloadedInfo(distro, release, arch string) (bool, int64) {
cachePath := filepath.Join("/var/cache/lxc/download", distro, release, arch)
func imageDownloadedInfo(templateID string) (bool, int64) {
cachePath, ok := officialLXCImageCachePath(templateID)
if !ok {
return false, 0
}
info, err := os.Stat(cachePath)
if err != nil || !info.IsDir() {
return false, 0
}
// Check directly for rootfs.tar.xz (some LXC versions store it here)
if fi, err := os.Stat(filepath.Join(cachePath, "rootfs.tar.xz")); err == nil {
return true, fi.Size()
}
if fi, err := os.Stat(filepath.Join(cachePath, "meta.tar.xz")); err == nil {
return true, fi.Size()
}
// Check one level deeper (LXC uses variant subdirectories like "default")
entries, err := os.ReadDir(cachePath)
if err != nil {
return false, 0
}
for _, entry := range entries {
if !entry.IsDir() {
continue
}
subPath := filepath.Join(cachePath, entry.Name())
if fi, err := os.Stat(filepath.Join(subPath, "rootfs.tar.xz")); err == nil {
return true, fi.Size()
}
if fi, err := os.Stat(filepath.Join(subPath, "meta.tar.xz")); err == nil {
return true, fi.Size()
for _, candidate := range []string{
filepath.Join(cachePath, "rootfs.tar.xz"),
filepath.Join(cachePath, "meta.tar.xz"),
filepath.Join(cachePath, "default", "rootfs.tar.xz"),
filepath.Join(cachePath, "default", "meta.tar.xz"),
} {
if fileInfo, err := os.Stat(candidate); err == nil && !fileInfo.IsDir() {
return true, fileInfo.Size()
}
}
return false, 0
}
func officialLXCImageCachePath(templateID string) (string, bool) {
arch := "amd64"
if runtime.GOARCH == "arm64" {
arch = "arm64"
}
base := "/var/cache/lxc/download"
switch templateID {
case "ubuntu-noble":
return filepath.Join(base, "ubuntu", "noble", arch), true
case "ubuntu-jammy":
return filepath.Join(base, "ubuntu", "jammy", arch), true
case "debian-trixie":
return filepath.Join(base, "debian", "trixie", arch), true
case "debian-bookworm":
return filepath.Join(base, "debian", "bookworm", arch), true
case "debian-bullseye":
return filepath.Join(base, "debian", "bullseye", arch), true
case "alpine-3.21":
return filepath.Join(base, "alpine", "3.21", arch), true
case "centos-9-stream":
return filepath.Join(base, "centos", "9-Stream", arch), true
case "archlinux-current":
return filepath.Join(base, "archlinux", "current", arch), true
case "fedora-44":
return filepath.Join(base, "fedora", "44", arch), true
case "rockylinux-10":
return filepath.Join(base, "rockylinux", "10", arch), true
default:
return "", false
}
}
func lxcTemplateDownloadedInfo(template lxc.Template) (bool, int64) {
if template.Custom {
return lxc.CustomImageDownloadedInfo(template.ID)
}
return imageDownloadedInfo(template.ID)
}
// getEnabledImageSet returns the set of enabled image IDs.
// If none have been explicitly set, all templates are enabled by default.
func getEnabledImageSet() map[string]bool {
@@ -227,12 +280,17 @@ func HandleImages(w http.ResponseWriter, r *http.Request) {
enabledSet := getEnabledImageSet()
cleanupOldImageDownloadErrors()
kvmAvailable := hostKVMAvailable()
templates := lxc.GetTemplates()
images := make([]ImageInfo, 0, len(templates)+len(kvm.GetImages()))
kvmImages := []kvm.Image{}
if kvmAvailable {
kvmImages = kvm.GetImages()
}
images := make([]ImageInfo, 0, len(templates))
for _, t := range templates {
dl := imageDownloadInfo(t.ID)
downloaded, size := imageDownloadedInfo(t.Distro, t.Release, t.Arch)
downloaded, size := lxcTemplateDownloadedInfo(t)
images = append(images, ImageInfo{
ID: t.ID,
Name: t.Name,
@@ -250,13 +308,15 @@ func HandleImages(w http.ResponseWriter, r *http.Request) {
Stage: dl.Stage,
Error: dl.Error,
SizeBytes: size,
Custom: t.Custom,
SHA256: t.SHA256,
})
}
for _, t := range kvm.GetImages() {
for _, t := range kvmImages {
dl := imageDownloadInfo(t.ID)
downloaded, size := kvm.ImageDownloadedInfo(t.ID)
manualPath := ""
if t.Distro == "windows" {
if t.IsWindows() {
manualPath = kvm.ImagePath(t.ID)
}
images = append(images, ImageInfo{
@@ -278,12 +338,260 @@ func HandleImages(w http.ResponseWriter, r *http.Request) {
SizeBytes: size,
ManualPath: manualPath,
Desktop: t.Desktop,
Provisioner: t.Provisioner,
Custom: t.Custom,
SHA256: t.SHA256,
})
}
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: images})
}
// HandleCustomKVMImages creates or removes administrator-defined LXC/KVM image sources.
func HandleCustomKVMImages(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodPost:
if !requireScope(w, r, "image:download") {
return
}
handleCustomKVMImageCreate(w, r)
case http.MethodDelete:
if !requireScope(w, r, "image:delete") {
return
}
handleCustomKVMImageDelete(w, r)
default:
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
}
}
func handleCustomKVMImageCreate(w http.ResponseWriter, r *http.Request) {
var req struct {
Type string `json:"type"`
Name string `json:"name"`
Description string `json:"description"`
Distro string `json:"distro"`
Release string `json:"release"`
Arch string `json:"arch"`
URL string `json:"url"`
Provisioner string `json:"provisioner"`
SHA256 string `json:"sha256"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
return
}
req.Name = strings.TrimSpace(req.Name)
req.Type = strings.ToLower(strings.TrimSpace(req.Type))
if req.Type == "" {
req.Type = config.VirtualizationKVM
}
req.Description = strings.TrimSpace(req.Description)
req.Distro = strings.ToLower(strings.TrimSpace(req.Distro))
req.Release = strings.ToLower(strings.TrimSpace(req.Release))
req.Arch = strings.ToLower(strings.TrimSpace(req.Arch))
req.URL = strings.TrimSpace(req.URL)
req.Provisioner = strings.ToLower(strings.TrimSpace(req.Provisioner))
req.SHA256 = strings.ToLower(strings.TrimSpace(req.SHA256))
if req.Name == "" || len(req.Name) > 100 {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "name must be between 1 and 100 characters"})
return
}
if len(req.Description) > 500 {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "description must not exceed 500 characters"})
return
}
if req.Arch != runtime.GOARCH || (req.Arch != "amd64" && req.Arch != "arm64") {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "image architecture must match the host architecture"})
return
}
if req.Type == config.VirtualizationLXC {
if !customImageFieldPattern.MatchString(req.Distro) {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "distro contains unsupported characters"})
return
}
if !customImageFieldPattern.MatchString(req.Release) {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "release contains unsupported characters"})
return
}
} else if req.Type == config.VirtualizationKVM {
switch req.Provisioner {
case config.KVMProvisionerLinuxCloudInit:
if !customImageFieldPattern.MatchString(req.Distro) {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "distro contains unsupported characters"})
return
}
if !customImageFieldPattern.MatchString(req.Release) {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "release contains unsupported characters"})
return
}
case config.KVMProvisionerWindows10:
if req.Arch != "amd64" {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Windows unattended installation currently requires an amd64 host"})
return
}
req.Distro = "windows"
req.Release = "10"
case config.KVMProvisionerWindows11:
if req.Arch != "amd64" {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Windows unattended installation currently requires an amd64 host"})
return
}
req.Distro = "windows"
req.Release = "11"
default:
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "unsupported unattended installation template"})
return
}
} else {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "type must be lxc or kvm"})
return
}
if len(req.URL) > 4096 {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "url must not exceed 4096 characters"})
return
}
if _, err := safehttp.ValidateURL(req.URL); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
return
}
if req.SHA256 != "" && !sha256Pattern.MatchString(req.SHA256) {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "sha256 must contain exactly 64 hexadecimal characters"})
return
}
if req.Type == config.VirtualizationLXC {
for _, existing := range lxc.GetTemplates() {
if strings.EqualFold(existing.Name, req.Name) {
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: "an image with this name already exists"})
return
}
if existing.Custom && existing.URL == req.URL {
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: "this image URL is already registered"})
return
}
}
} else {
for _, existing := range kvm.GetImages() {
if strings.EqualFold(existing.Name, req.Name) {
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: "an image with this name already exists"})
return
}
if existing.Custom && existing.URL == req.URL {
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: "this image URL is already registered"})
return
}
}
}
random := make([]byte, 5)
if _, err := rand.Read(random); err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "failed to generate image ID"})
return
}
createdAt := time.Now().Format("2006-01-02 15:04:05")
if req.Type == config.VirtualizationLXC {
image := config.CustomLXCImage{
ID: "custom-lxc-" + hex.EncodeToString(random),
Name: req.Name,
Description: req.Description,
Distro: req.Distro,
Release: req.Release,
Arch: req.Arch,
URL: req.URL,
SHA256: req.SHA256,
CreatedAt: createdAt,
}
if err := config.AddCustomLXCImage(image); err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "failed to save custom image: " + err.Error()})
return
}
jsonResponse(w, http.StatusCreated, APIResponse{Success: true, Message: "Custom image added", Data: image})
return
}
image := config.CustomKVMImage{
ID: "custom-kvm-" + hex.EncodeToString(random), Name: req.Name, Description: req.Description,
Distro: req.Distro, Release: req.Release, Arch: req.Arch, URL: req.URL,
Provisioner: req.Provisioner, SHA256: req.SHA256, CreatedAt: createdAt,
}
if err := config.AddCustomKVMImage(image); err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "failed to save custom image: " + err.Error()})
return
}
jsonResponse(w, http.StatusCreated, APIResponse{Success: true, Message: "Custom image added", Data: image})
}
func handleCustomKVMImageDelete(w http.ResponseWriter, r *http.Request) {
var req struct {
ID string `json:"id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.ID) == "" {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "id required"})
return
}
req.ID = strings.TrimSpace(req.ID)
kvmImage := kvm.FindImage(req.ID)
lxcImage := lxc.FindTemplate(req.ID)
isCustomKVM := kvmImage != nil && kvmImage.Custom
isCustomLXC := lxcImage != nil && lxcImage.Custom
if !isCustomKVM && !isCustomLXC {
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Custom image not found"})
return
}
if isImageDownloadActive(req.ID) {
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: "Image is downloading; cancel it before removing the source"})
return
}
for i := range config.AppConfig.Containers {
if config.AppConfig.Containers[i].Template == req.ID {
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: "This image is still used by a container"})
return
}
}
for i := range config.AppConfig.Tasks {
task := &config.AppConfig.Tasks[i]
if task.Status != "pending" && task.Status != "running" {
continue
}
var taskConfig struct {
TemplateID string `json:"template_id"`
}
_ = json.Unmarshal([]byte(task.Config), &taskConfig)
if task.TemplateID == req.ID || taskConfig.TemplateID == req.ID {
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: "This image is still referenced by an active task"})
return
}
}
var deleteErr error
if isCustomLXC {
deleteErr = lxc.DeleteCustomImage(req.ID)
} else {
deleteErr = kvm.DeleteImage(req.ID)
}
if deleteErr != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to delete image cache: " + deleteErr.Error()})
return
}
removeImageEnabled(req.ID)
var removed bool
var err error
if isCustomLXC {
removed, err = config.RemoveCustomLXCImage(req.ID)
} else {
removed, err = config.RemoveCustomKVMImage(req.ID)
}
if err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to remove custom image: " + err.Error()})
return
}
if !removed {
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Custom image not found"})
return
}
clearImageDownload(req.ID)
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Custom image removed"})
}
// HandleImageDownload starts a template image download in the background.
func HandleImageDownload(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
@@ -301,7 +609,6 @@ func HandleImageDownload(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "template_id required"})
return
}
tmpl := lxc.FindTemplate(req.TemplateID)
if tmpl == nil {
image := kvm.FindImage(req.TemplateID)
@@ -309,6 +616,14 @@ func HandleImageDownload(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Template not found"})
return
}
if !hostKVMAvailable() {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "KVM is not available on this host"})
return
}
if _, err := config.SelectStoragePoolForContent(config.StorageContentImages, "", 1024*1024*1024); err != nil {
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: err.Error()})
return
}
if ok, _ := kvm.ImageDownloadedInfo(image.ID); ok {
ensureImageEnabled(image.ID)
clearImageDownload(image.ID)
@@ -349,22 +664,79 @@ func HandleImageDownload(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusAccepted, APIResponse{Success: true, Message: "Download started"})
return
}
if !beginLXCImageDownload() {
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: "Another LXC image download is active"})
return
}
lxcDownloadHandedOff := false
defer func() {
if !lxcDownloadHandedOff {
endLXCImageDownload()
}
}()
imagePool, err := config.SelectStoragePoolForContent(
config.StorageContentImages,
"",
dirSizeBytes("/var/cache/lxc/download")+1024*1024*1024,
)
if err != nil {
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: err.Error()})
return
}
if err := ensureLXCImageCachePool(*imagePool); err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
return
}
// Already downloaded? Just enable if needed.
if isImageDownloaded(tmpl.Distro, tmpl.Release, tmpl.Arch) {
if downloaded, _ := lxcTemplateDownloadedInfo(*tmpl); downloaded {
ensureImageEnabled(tmpl.ID)
clearImageDownload(tmpl.ID)
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Already downloaded"})
return
}
ctx, ok := startImageDownload(tmpl.ID, "lxc-create")
startStage := "lxc-create"
if tmpl.Custom {
startStage = "downloading"
}
ctx, ok := startImageDownload(tmpl.ID, startStage)
if !ok {
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: "Already downloading"})
return
}
if tmpl.Custom {
go func(tmpl lxc.Template) {
defer endLXCImageDownload()
err := lxc.DownloadCustomImageWithProgress(ctx, tmpl, func(progress lxc.CustomImageDownloadProgress) {
updateImageDownload(tmpl.ID, func(status *imageDownloadStatus) {
status.Stage = progress.Stage
status.DownloadedBytes = progress.DownloadedBytes
status.TotalBytes = progress.TotalBytes
status.Progress = progress.Percent
})
})
if err != nil {
if ctx.Err() != nil {
_ = os.Remove(lxc.CustomImagePath(tmpl.ID) + ".tmp")
_ = os.Remove(lxc.CustomImagePath(tmpl.ID))
finishImageDownload(tmpl.ID, nil)
return
}
finishImageDownload(tmpl.ID, err)
return
}
ensureImageEnabled(tmpl.ID)
finishImageDownload(tmpl.ID, nil)
}(*tmpl)
lxcDownloadHandedOff = true
jsonResponse(w, http.StatusAccepted, APIResponse{Success: true, Message: "Download started"})
return
}
go func(tmpl lxc.Template) {
defer endLXCImageDownload()
// Download via lxc-create with a temp container, then destroy it.
tmpName := lxcImageDownloadTempName(tmpl.ID)
args := []string{"-n", tmpName, "-t", "download", "--",
@@ -376,7 +748,7 @@ func HandleImageDownload(w http.ResponseWriter, r *http.Request) {
st.Stage = "lxc-create"
})
cmd := exec.CommandContext(ctx, "lxc-create", args...)
output, err := cmd.CombinedOutput()
output, err := runLXCImageDownloadCommand(cmd, tmpl.ID)
// Clean up the temp container unconditionally.
cleanupLXCImageDownloadTemp(tmpl.ID)
@@ -393,10 +765,154 @@ func HandleImageDownload(w http.ResponseWriter, r *http.Request) {
ensureImageEnabled(tmpl.ID)
finishImageDownload(tmpl.ID, nil)
}(*tmpl)
lxcDownloadHandedOff = true
jsonResponse(w, http.StatusAccepted, APIResponse{Success: true, Message: "Download started"})
}
type lxcImageDownloadCommandResult struct {
output []byte
err error
}
func runLXCImageDownloadCommand(cmd *exec.Cmd, templateID string) ([]byte, error) {
startedAt := time.Now()
done := make(chan lxcImageDownloadCommandResult, 1)
go func() {
output, err := cmd.CombinedOutput()
done <- lxcImageDownloadCommandResult{output: output, err: err}
}()
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
var lastBytes int64
for {
select {
case result := <-done:
return result.output, result.err
case <-ticker.C:
downloadedBytes := newestLXCRootfsDownloadSize(startedAt)
if downloadedBytes <= 0 || downloadedBytes == lastBytes {
continue
}
lastBytes = downloadedBytes
updateImageDownload(templateID, func(st *imageDownloadStatus) {
st.Stage = "downloading"
st.DownloadedBytes = downloadedBytes
})
}
}
}
func newestLXCRootfsDownloadSize(startedAt time.Time) int64 {
matches, _ := filepath.Glob("/tmp/tmp.*/rootfs.tar.xz")
var newestTime time.Time
var newestSize int64
for _, match := range matches {
info, err := os.Stat(match)
if err != nil || info.IsDir() || info.ModTime().Before(startedAt.Add(-5*time.Second)) {
continue
}
if info.ModTime().After(newestTime) {
newestTime = info.ModTime()
newestSize = info.Size()
}
}
return newestSize
}
func ensureLXCImageCachePool(pool config.StoragePool) error {
lxcImageCacheMu.Lock()
defer lxcImageCacheMu.Unlock()
cachePath := "/var/cache/lxc/download"
targetPath := filepath.Join(pool.Path, "images", "lxc")
targetAbs, err := filepath.Abs(targetPath)
if err != nil {
return err
}
if err := os.MkdirAll(targetAbs, 0755); err != nil {
return fmt.Errorf("failed to create LXC image storage: %v", err)
}
info, err := os.Lstat(cachePath)
if os.IsNotExist(err) {
if err := os.MkdirAll(filepath.Dir(cachePath), 0755); err != nil {
return err
}
return os.Symlink(targetAbs, cachePath)
}
if err != nil {
return err
}
sourcePath := cachePath
linked := info.Mode()&os.ModeSymlink != 0
if linked {
sourcePath, err = filepath.EvalSymlinks(cachePath)
if err != nil {
return fmt.Errorf("failed to resolve LXC image cache: %v", err)
}
}
sourceAbs, err := filepath.Abs(sourcePath)
if err != nil {
return err
}
if sourceAbs == targetAbs {
return nil
}
if strings.HasPrefix(targetAbs, sourceAbs+string(os.PathSeparator)) || strings.HasPrefix(sourceAbs, targetAbs+string(os.PathSeparator)) {
return fmt.Errorf("LXC image cache source and target must not be nested")
}
if !info.IsDir() && !linked {
return fmt.Errorf("LXC image cache is not a directory: %s", cachePath)
}
if output, err := exec.Command("cp", "-a", sourceAbs+string(os.PathSeparator)+".", targetAbs+string(os.PathSeparator)).CombinedOutput(); err != nil {
return fmt.Errorf("failed to migrate LXC image cache: %v, output: %s", err, strings.TrimSpace(string(output)))
}
tempLink := fmt.Sprintf("%s.clicd-new-%d", cachePath, time.Now().UnixNano())
if err := os.Symlink(targetAbs, tempLink); err != nil {
return err
}
if linked {
if err := os.Rename(tempLink, cachePath); err != nil {
_ = os.Remove(tempLink)
return fmt.Errorf("failed to switch LXC image cache: %v", err)
}
if isManagedLXCImageCachePath(sourceAbs) {
_ = os.RemoveAll(sourceAbs)
}
return nil
}
backupPath := fmt.Sprintf("%s.clicd-backup-%d", cachePath, time.Now().UnixNano())
if err := os.Rename(cachePath, backupPath); err != nil {
_ = os.Remove(tempLink)
return fmt.Errorf("failed to prepare LXC image cache migration: %v", err)
}
if err := os.Rename(tempLink, cachePath); err != nil {
_ = os.Rename(backupPath, cachePath)
_ = os.Remove(tempLink)
return fmt.Errorf("failed to activate LXC image storage: %v", err)
}
if err := os.RemoveAll(backupPath); err != nil {
return fmt.Errorf("LXC image cache migrated but old cache cleanup failed: %v", err)
}
return nil
}
func isManagedLXCImageCachePath(path string) bool {
path = filepath.Clean(path)
for _, pool := range config.StoragePoolsForContent(config.StorageContentImages) {
if path == filepath.Clean(filepath.Join(pool.Path, "images", "lxc")) {
return true
}
}
return path == filepath.Clean("/var/lib/clicd/images/lxc")
}
// HandleImageCancel cancels an in-progress image download.
func HandleImageCancel(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
@@ -432,7 +948,12 @@ func HandleImageCancel(w http.ResponseWriter, r *http.Request) {
os.Remove(kvm.ImagePath(image.ID))
}
if tmpl := lxc.FindTemplate(req.TemplateID); tmpl != nil {
go cleanupLXCImageDownloadTemp(tmpl.ID)
if tmpl.Custom {
_ = os.Remove(lxc.CustomImagePath(tmpl.ID) + ".tmp")
_ = os.Remove(lxc.CustomImagePath(tmpl.ID))
} else {
go cleanupLXCImageDownloadTemp(tmpl.ID)
}
}
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Cancel requested"})
}
@@ -473,9 +994,22 @@ func HandleImageDelete(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Template not found"})
return
}
if tmpl.Custom {
if err := lxc.DeleteCustomImage(tmpl.ID); err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to delete image cache: " + err.Error()})
return
}
removeImageEnabled(tmpl.ID)
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Deleted"})
return
}
// Remove cache directory
cachePath := filepath.Join("/var/cache/lxc/download", tmpl.Distro, tmpl.Release, tmpl.Arch)
cachePath, ok := officialLXCImageCachePath(tmpl.ID)
if !ok {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Template cache path is not managed by CLICD"})
return
}
if err := os.RemoveAll(cachePath); err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{
Success: false,
@@ -531,11 +1065,36 @@ func HandleEnabledImages(w http.ResponseWriter, r *http.Request) {
runtime := runtimeFromRequest(r.URL.Query().Get("type"))
enabledSet := getEnabledImageSet()
var subUser *config.SubUser
var targetContainer *config.Container
currentImageIDs := map[string]bool{}
if isSubUserRequest(r) {
subUser = subUserFromRequest(r)
if identifier := r.URL.Query().Get("container"); identifier != "" {
targetContainer = containerByIdentifier(identifier)
if targetContainer == nil || !isContainerAllowedForRequest(r, identifier) {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Access denied to this container"})
return
}
currentImageIDs[targetContainer.Template] = true
} else {
for _, id := range subUserCurrentImageIDs(subUser) {
currentImageIDs[id] = true
}
}
}
result := make([]map[string]string, 0)
if runtime == config.VirtualizationKVM {
if !hostKVMAvailable() {
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: result})
return
}
for _, t := range kvm.GetImages() {
if downloaded, _ := kvm.ImageDownloadedInfo(t.ID); enabledSet[t.ID] && downloaded {
if subUser != nil && !isImageAllowedForSubUser(subUser, targetContainer, t.ID) {
continue
}
if downloaded, _ := kvm.ImageDownloadedInfo(t.ID); downloaded && (enabledSet[t.ID] || currentImageIDs[t.ID]) {
result = append(result, map[string]string{
"id": t.ID, "name": t.Name, "distro": t.Distro, "release": t.Release, "arch": t.Arch,
"description": t.Description, "type": config.VirtualizationKVM, "desktop": t.Desktop,
@@ -544,7 +1103,10 @@ func HandleEnabledImages(w http.ResponseWriter, r *http.Request) {
}
} else {
for _, t := range lxc.GetTemplates() {
if enabledSet[t.ID] && isImageDownloaded(t.Distro, t.Release, t.Arch) {
if subUser != nil && !isImageAllowedForSubUser(subUser, targetContainer, t.ID) {
continue
}
if downloaded, _ := lxcTemplateDownloadedInfo(t); downloaded && (enabledSet[t.ID] || currentImageIDs[t.ID]) {
result = append(result, map[string]string{
"id": t.ID, "name": t.Name, "distro": t.Distro, "release": t.Release, "arch": t.Arch,
"variant": t.Variant, "description": t.Description, "type": config.VirtualizationLXC,
@@ -560,9 +1122,49 @@ func isTemplateEnabledAndDownloaded(templateID string) bool {
return isImageEnabledAndDownloaded(templateID, runtimeFromTemplateID(templateID))
}
func imageTemplateExists(templateID string) bool {
return lxc.FindTemplate(templateID) != nil || kvm.FindImage(templateID) != nil
}
func isImageDownloadedForRuntime(templateID string, runtime string) bool {
runtime = runtimeFromRequest(runtime)
if runtime == config.VirtualizationKVM {
if !hostKVMAvailable() {
return false
}
image := kvm.FindImage(templateID)
if image == nil {
return false
}
downloaded, _ := kvm.ImageDownloadedInfo(image.ID)
return downloaded
}
tmpl := lxc.FindTemplate(templateID)
if tmpl == nil {
return false
}
downloaded, _ := lxcTemplateDownloadedInfo(*tmpl)
return downloaded
}
func isTemplateAvailableForRequest(r *http.Request, c *config.Container, templateID string, runtime string) bool {
if isSubUserRequest(r) {
if !isTemplateAllowedForRequest(r, c, templateID) {
return false
}
if c != nil && c.Template == templateID {
return isImageDownloadedForRuntime(templateID, runtime)
}
}
return isImageEnabledAndDownloaded(templateID, runtime)
}
func isImageEnabledAndDownloaded(templateID string, runtime string) bool {
runtime = runtimeFromRequest(runtime)
if runtime == config.VirtualizationKVM {
if !hostKVMAvailable() {
return false
}
image := kvm.FindImage(templateID)
if image == nil {
return false
@@ -576,7 +1178,15 @@ func isImageEnabledAndDownloaded(templateID string, runtime string) bool {
return false
}
enabledSet := getEnabledImageSet()
return enabledSet[tmpl.ID] && isImageDownloaded(tmpl.Distro, tmpl.Release, tmpl.Arch)
downloaded, _ := lxcTemplateDownloadedInfo(*tmpl)
return enabledSet[tmpl.ID] && downloaded
}
func hostKVMAvailable() bool {
if runtime.GOARCH != "amd64" && runtime.GOARCH != "arm64" {
return false
}
return fileExists("/dev/kvm") && commandExists("virsh") && commandExists(kvmQEMUCheckKey())
}
func ensureImageEnabled(id string) {
+133
View File
@@ -0,0 +1,133 @@
package api
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"runtime"
"strings"
"testing"
)
func TestCustomKVMImageCreateRejectsInvalidSource(t *testing.T) {
payload := map[string]string{
"name": "Invalid Source",
"distro": "ubuntu",
"release": "noble",
"arch": runtime.GOARCH,
"url": "file:///etc/passwd",
"provisioner": "linux-cloud-init",
}
body, err := json.Marshal(payload)
if err != nil {
t.Fatal(err)
}
request := httptest.NewRequest(http.MethodPost, "/api/images/custom", bytes.NewReader(body))
response := httptest.NewRecorder()
HandleCustomKVMImages(response, request)
if response.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d; body=%s", response.Code, http.StatusBadRequest, response.Body.String())
}
}
func TestCustomKVMImageCreateRejectsArchitectureMismatch(t *testing.T) {
otherArch := "arm64"
if runtime.GOARCH == otherArch {
otherArch = "amd64"
}
payload := map[string]string{
"name": "Wrong Architecture",
"distro": "ubuntu",
"release": "noble",
"arch": otherArch,
"url": "https://example.test/image.qcow2",
"provisioner": "linux-cloud-init",
}
body, err := json.Marshal(payload)
if err != nil {
t.Fatal(err)
}
request := httptest.NewRequest(http.MethodPost, "/api/images/custom", bytes.NewReader(body))
response := httptest.NewRecorder()
HandleCustomKVMImages(response, request)
if response.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d; body=%s", response.Code, http.StatusBadRequest, response.Body.String())
}
}
func TestCustomLXCImageCreateRejectsInvalidSource(t *testing.T) {
payload := map[string]string{
"type": "lxc",
"name": "Invalid LXC Source",
"distro": "alpine",
"release": "3.21",
"arch": runtime.GOARCH,
"url": "file:///tmp/rootfs.tar.xz",
}
body, err := json.Marshal(payload)
if err != nil {
t.Fatal(err)
}
request := httptest.NewRequest(http.MethodPost, "/api/images/custom", bytes.NewReader(body))
response := httptest.NewRecorder()
HandleCustomKVMImages(response, request)
if response.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d; body=%s", response.Code, http.StatusBadRequest, response.Body.String())
}
}
func TestCustomImageCreateRejectsPrivateNetworkSource(t *testing.T) {
for _, imageType := range []string{"lxc", "kvm"} {
t.Run(imageType, func(t *testing.T) {
payload := map[string]string{
"type": imageType,
"name": "Private Network Source",
"distro": "ubuntu",
"release": "noble",
"arch": runtime.GOARCH,
"url": "http://169.254.169.254/latest/meta-data",
"provisioner": "linux-cloud-init",
}
body, err := json.Marshal(payload)
if err != nil {
t.Fatal(err)
}
request := httptest.NewRequest(http.MethodPost, "/api/images/custom", bytes.NewReader(body))
response := httptest.NewRecorder()
HandleCustomKVMImages(response, request)
if response.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d; body=%s", response.Code, http.StatusBadRequest, response.Body.String())
}
})
}
}
func TestOfficialLXCImageCachePathUsesAllowlist(t *testing.T) {
cachePath, ok := officialLXCImageCachePath("debian-trixie")
if !ok {
t.Fatal("known template cache path was rejected")
}
normalized := filepath.ToSlash(cachePath)
if !strings.Contains(normalized, "/debian/trixie/") {
t.Fatalf("cache path = %q, want Debian trixie path", cachePath)
}
for _, templateID := range []string{
"../../../etc",
"custom-lxc-attacker",
"debian-trixie/../../etc",
} {
if cachePath, ok := officialLXCImageCachePath(templateID); ok || cachePath != "" {
t.Fatalf("officialLXCImageCachePath(%q) = %q, %v; want rejection", templateID, cachePath, ok)
}
}
}
+55 -1
View File
@@ -1,6 +1,9 @@
package api
import "net/http"
import (
"encoding/json"
"net/http"
)
func HandleIPv6Status(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
@@ -22,3 +25,54 @@ func assignIPv6(w http.ResponseWriter, r *http.Request, id int) {
}
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "IPv6 assigned", Data: c})
}
type ipAssignmentRequest struct {
Mode string `json:"mode"`
Auto *bool `json:"auto,omitempty"`
Count int `json:"count,omitempty"`
Addresses []string `json:"addresses,omitempty"`
}
func (req ipAssignmentRequest) allocation() ([]string, int, bool) {
auto := req.Mode == "random" || req.Mode == "auto"
if req.Mode == "custom" {
auto = false
}
if req.Mode == "clear" || req.Mode == "none" {
return nil, 0, false
}
if req.Auto != nil {
auto = *req.Auto
}
return req.Addresses, req.Count, auto
}
func updatePublicIPv4(w http.ResponseWriter, r *http.Request, id int) {
var req ipAssignmentRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
return
}
addresses, count, auto := req.allocation()
c, err := updatePublicIPv4ByRuntime(id, addresses, count, auto)
if err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
return
}
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Public IPv4 assignments updated", Data: c})
}
func updateIPv6Addresses(w http.ResponseWriter, r *http.Request, id int) {
var req ipAssignmentRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
return
}
addresses, count, auto := req.allocation()
c, err := updateIPv6ByRuntime(id, addresses, count, auto)
if err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
return
}
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "IPv6 assignments updated", Data: c})
}
+80
View File
@@ -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
}
+104
View File
@@ -0,0 +1,104 @@
package api
import (
"fmt"
"time"
"clicd/internal/config"
"clicd/internal/kvm"
"clicd/internal/lxc"
)
// CaptureRuntimeRestoreState records which managed workloads are actually
// running before the CLICD service exits. On the next host boot, only those
// workloads are started again.
func CaptureRuntimeRestoreState() {
if config.AppConfig == nil {
return
}
lxcManager := lxc.NewManager()
kvmManager := kvm.NewManager()
changed := false
for i := range config.AppConfig.Containers {
c := &config.AppConfig.Containers[i]
status, err := runtimeStatus(*c, lxcManager, kvmManager)
if err != nil {
fmt.Printf("Warning: failed to capture runtime state for %s: %v\n", c.Name, err)
continue
}
restore := status == "running"
if c.RestoreOnHostBoot != restore {
c.RestoreOnHostBoot = restore
changed = true
}
if status != "" && c.Status != status {
c.Status = status
changed = true
}
}
if changed {
if err := config.SaveConfig(); err != nil {
fmt.Printf("Warning: failed to save host boot restore state: %v\n", err)
}
}
}
func StartHostBootRestore() {
go RestoreHostBootState()
}
func RestoreHostBootState() {
if config.AppConfig == nil {
return
}
time.Sleep(2 * time.Second)
lxcManager := lxc.NewManager()
kvmManager := kvm.NewManager()
containers := append([]config.Container(nil), config.AppConfig.Containers...)
for _, c := range containers {
if !c.RestoreOnHostBoot {
continue
}
if c.PolicyBlocked {
fmt.Printf("Skipping host boot restore for %s: policy blocked\n", c.Name)
continue
}
if lxc.IsExpired(c) {
fmt.Printf("Skipping host boot restore for %s: expired at %s\n", c.Name, c.ExpiresAt)
continue
}
status, err := runtimeStatus(c, lxcManager, kvmManager)
if err == nil && status == "running" {
config.UpdateContainerStatusAndRestore(c.ID, "running", true)
if !c.IsKVM() {
_ = lxcManager.ApplyPortMappings(c.ID)
} else {
_ = lxc.NewManager().ApplyPortMappings(c.ID)
}
continue
}
fmt.Printf("Restoring workload after host boot: %s (ID=%d)\n", c.Name, c.ID)
if c.IsKVM() {
if err := kvmManager.StartContainer(c.ID); err != nil {
fmt.Printf("Warning: failed to restore KVM %s: %v\n", c.Name, err)
}
continue
}
if err := lxcManager.StartContainer(c.ID); err != nil {
fmt.Printf("Warning: failed to restore LXC %s: %v\n", c.Name, err)
}
}
lxc.EnsureAllRunningPortMappings()
}
func runtimeStatus(c config.Container, lxcManager *lxc.Manager, kvmManager *kvm.Manager) (string, error) {
if c.IsKVM() {
return kvmManager.GetContainerStatus(c.VirshName())
}
return lxcManager.GetContainerStatus(c.LxcName())
}
+320 -24
View File
@@ -1,7 +1,9 @@
package api
import (
"encoding/json"
"net/http"
"net/netip"
"sort"
"strconv"
@@ -15,18 +17,53 @@ type routeCapacity struct {
Total string `json:"total"`
}
type nat4PortRange struct {
Start int `json:"start"`
End int `json:"end"`
}
type nat4Networks struct {
LXC config.NATNetwork `json:"lxc"`
KVM config.NATNetwork `json:"kvm"`
}
type nat4Route struct {
ContainerID int `json:"container_id"`
ContainerName string `json:"container_name"`
LXCName string `json:"lxc_name"`
Status string `json:"status"`
IP string `json:"ip"`
HostIP string `json:"host_ip"`
HostPort int `json:"host_port"`
ContainerPort int `json:"container_port"`
Protocol string `json:"protocol"`
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 lanDHCPRoute 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"`
MACAddress string `json:"mac_address,omitempty"`
Mode string `json:"mode"`
}
type ipv6Route struct {
ContainerID int `json:"container_id"`
ContainerName string `json:"container_name"`
@@ -38,32 +75,87 @@ type ipv6Route struct {
}
type routingResponse struct {
NAT4 routeCapacity `json:"nat4"`
IPv6 routeCapacity `json:"ipv6"`
NAT4Mappings []nat4Route `json:"nat4_mappings"`
IPv6Assignments []ipv6Route `json:"ipv6_assignments"`
IPv6Prefixes []lxc.IPv6PrefixInfo `json:"ipv6_prefixes"`
NAT4 routeCapacity `json:"nat4"`
NAT4PortRange nat4PortRange `json:"nat4_port_range"`
NAT4NextPort int `json:"nat4_next_port"`
NAT4Networks nat4Networks `json:"nat4_networks"`
IPv4 routeCapacity `json:"ipv4"`
LANDHCP routeCapacity `json:"lan_dhcp"`
IPv6 routeCapacity `json:"ipv6"`
HostPublicIPv4 lxc.PublicIPInfo `json:"host_public_ipv4"`
PublicIPv4Addresses []lxc.PublicIPInfo `json:"public_ipv4_addresses"`
IPv4Assignments []ipv4Route `json:"ipv4_assignments"`
LANDHCPAssignments []lanDHCPRoute `json:"lan_dhcp_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"`
NAT4PortRange *nat4PortRange `json:"nat4_port_range"`
}
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) {
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"})
}
}
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:read") {
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
}
nat4Mappings := make([]nat4Route, 0)
usedPorts := map[int]bool{}
ipv4Assignments := make([]ipv4Route, 0)
lanDHCPAssignments := make([]lanDHCPRoute, 0)
ipv6Assignments := make([]ipv6Route, 0)
const nat4StartPort = 20000
const nat4EndPort = 65535
nat4StartPort, nat4EndPort := config.NATPortRange()
for _, c := range config.AppConfig.Containers {
for i := range config.AppConfig.Containers {
c := &config.AppConfig.Containers[i]
for _, pm := range c.PortMappings {
if pm.HostPort >= nat4StartPort && pm.HostPort <= nat4EndPort {
if config.NATPortInRange(pm.HostPort) {
usedPorts[pm.HostPort] = true
}
nat4Mappings = append(nat4Mappings, nat4Route{
@@ -72,48 +164,99 @@ func HandleRouting(w http.ResponseWriter, r *http.Request) {
LXCName: c.LxcName(),
Status: c.Status,
IP: c.IP,
HostIP: pm.HostIP,
HostPort: pm.HostPort,
ContainerPort: pm.ContainerPort,
Protocol: pm.Protocol,
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()
if c.UsesLANIPv4() {
lanDHCPAssignments = append(lanDHCPAssignments, lanDHCPRoute{
ContainerID: c.ID,
ContainerName: c.Name,
LXCName: c.LxcName(),
Status: c.Status,
Address: c.IP,
Interface: c.LANInterface,
PrefixLen: c.LANIPv4PrefixLen,
Gateway: c.LANIPv4Gateway,
MACAddress: c.MACAddress,
Mode: c.LANIPv4Mode,
})
}
for _, ip := range c.IPv6Addresses {
if ip.Address == "" {
continue
}
ipv6Assignments = append(ipv6Assignments, ipv6Route{
ContainerID: c.ID,
ContainerName: c.Name,
LXCName: c.LxcName(),
Status: c.Status,
Address: c.IPv6,
PrefixLen: c.IPv6PrefixLen,
Interface: c.IPv6Interface,
Address: ip.Address,
PrefixLen: ip.PrefixLen,
Interface: ip.Interface,
})
}
}
sort.SliceStable(nat4Mappings, func(i, j int) bool {
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].HostPort < nat4Mappings[j].HostPort
})
sort.SliceStable(ipv4Assignments, func(i, j int) bool {
return ipv4Assignments[i].Address < ipv4Assignments[j].Address
})
sort.SliceStable(lanDHCPAssignments, func(i, j int) bool {
if lanDHCPAssignments[i].Interface == lanDHCPAssignments[j].Interface {
return lanDHCPAssignments[i].ContainerName < lanDHCPAssignments[j].ContainerName
}
return lanDHCPAssignments[i].Interface < lanDHCPAssignments[j].Interface
})
sort.SliceStable(ipv6Assignments, func(i, j int) bool {
return ipv6Assignments[i].Address < ipv6Assignments[j].Address
})
const totalNAT4Ports = nat4EndPort - nat4StartPort + 1
totalNAT4Ports := config.NATPortCapacity()
nat4Used := len(usedPorts)
nat4Remaining := totalNAT4Ports - nat4Used
if nat4Remaining < 0 {
nat4Remaining = 0
}
nat4NextPort, _ := config.PreviewSSHPortExcluding(nil)
prefixes := lxc.DetectPublicIPv6Prefixes()
ipv6Total := "0"
ipv6Remaining := "0"
if len(prefixes) > 0 {
ipv6Total = lxc.IPv6PrefixCapacity(prefixes[0].PrefixLen)
ipv6Remaining = subtractCapacity(ipv6Total, len(ipv6Assignments))
hostPublicIPv4 := lxc.DetectPublicIPv4()
publicIPv4s := lxc.DetectPublicIPv4Candidates()
ipv4Total := len(publicIPv4s)
ipv4Used := len(ipv4Assignments)
ipv4Remaining := ipv4Total - ipv4Used
if ipv4Remaining < 0 {
ipv4Remaining = 0
}
ipv6Total := totalIPv6Capacity(prefixes)
ipv6Remaining := subtractCapacity(ipv6Total, len(ipv6Assignments))
jsonResponse(w, http.StatusOK, APIResponse{
Success: true,
@@ -123,18 +266,171 @@ func HandleRouting(w http.ResponseWriter, r *http.Request) {
Remaining: strconv.Itoa(nat4Remaining),
Total: strconv.Itoa(totalNAT4Ports),
},
NAT4PortRange: nat4PortRange{
Start: nat4StartPort,
End: nat4EndPort,
},
NAT4NextPort: nat4NextPort,
NAT4Networks: nat4Networks{
LXC: config.LXCNATNetwork(),
KVM: config.KVMNATNetwork(),
},
IPv4: routeCapacity{
Used: ipv4Used,
Remaining: strconv.Itoa(ipv4Remaining),
Total: strconv.Itoa(ipv4Total),
},
LANDHCP: routeCapacity{
Used: len(lanDHCPAssignments),
Remaining: "DHCP",
Total: "DHCP",
},
IPv6: routeCapacity{
Used: len(ipv6Assignments),
Remaining: ipv6Remaining,
Total: ipv6Total,
},
NAT4Mappings: nat4Mappings,
IPv6Assignments: ipv6Assignments,
IPv6Prefixes: prefixes,
HostPublicIPv4: hostPublicIPv4,
PublicIPv4Addresses: publicIPv4s,
IPv4Assignments: ipv4Assignments,
LANDHCPAssignments: lanDHCPAssignments,
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.NAT4PortRange != nil {
start, end, err := config.NormalizeNATPortRange(req.NAT4PortRange.Start, req.NAT4PortRange.End)
if err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
return
}
config.AppConfig.NATPortStart = start
config.AppConfig.NATPortEnd = end
if config.AppConfig.NextSSHPort < start || config.AppConfig.NextSSHPort > end {
config.AppConfig.NextSSHPort = start
}
}
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 {
if total == "" || total == "0" {
return "0"
+68
View File
@@ -0,0 +1,68 @@
package api
import (
"encoding/json"
"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")
}
}
func TestHandleRoutingGetReturnsConfiguredNextNATPort(t *testing.T) {
previous := config.AppConfig
t.Cleanup(func() { config.AppConfig = previous })
config.AppConfig = &config.ClicdConfig{
NATPortStart: 30000,
NATPortEnd: 35000,
NextSSHPort: 30000,
Containers: []config.Container{{
PortMappings: []config.PortMapping{{HostPort: 30000}},
}},
}
req := httptest.NewRequest(http.MethodGet, "/api/v1/routing", nil)
req = withAuthContext(req, AuthContext{
Type: authTypeAPIKey,
Scopes: []string{"routing:read"},
})
rec := httptest.NewRecorder()
handleRoutingGet(rec, req)
var response struct {
Success bool `json:"success"`
Data struct {
NAT4PortRange nat4PortRange `json:"nat4_port_range"`
NAT4NextPort int `json:"nat4_next_port"`
} `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
t.Fatal(err)
}
if !response.Success {
t.Fatalf("routing response was unsuccessful: %s", rec.Body.String())
}
if response.Data.NAT4PortRange.Start != 30000 || response.Data.NAT4PortRange.End != 35000 {
t.Fatalf("NAT range = %+v", response.Data.NAT4PortRange)
}
if response.Data.NAT4NextPort != 30001 {
t.Fatalf("next NAT port = %d, want 30001", response.Data.NAT4NextPort)
}
}
+49 -6
View File
@@ -13,10 +13,16 @@ import (
var kvmManager = kvm.NewManager()
const noNetworkSelectedMessage = "请勾选任意一个可用网络"
func runtimeFromRequest(value string) string {
return config.NormalizeVirtualization(value)
}
func hasRequestedNetwork(cfg lxc.ContainerConfig) bool {
return cfg.WantsNAT() || cfg.WantsLANIPv4() || cfg.AssignIPv4 || len(cfg.PublicIPv4s) > 0 || cfg.AssignIPv6 || len(cfg.IPv6Addresses) > 0
}
func runtimeFromTemplateID(templateID string) string {
if kvm.FindImage(templateID) != nil {
return config.VirtualizationKVM
@@ -26,12 +32,33 @@ func runtimeFromTemplateID(templateID string) string {
func createByRuntime(cfg lxc.ContainerConfig) error {
cfg.Virtualization = runtimeFromRequest(cfg.Virtualization)
cfg.NormalizeResourceAliases()
if cfg.Virtualization == config.VirtualizationKVM {
return kvmManager.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 {
c := config.FindContainer(id)
if c != nil && c.IsKVM() {
@@ -64,12 +91,12 @@ func destroyByRuntime(id int) error {
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)
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) {
@@ -88,6 +115,22 @@ func assignIPv6ByRuntime(id int) (*config.Container, error) {
return lxcManager.AssignIPv6(id)
}
func updatePublicIPv4ByRuntime(id int, requested []string, count int, auto bool) (*config.Container, error) {
c := config.FindContainer(id)
if c != nil && c.IsKVM() {
return kvmManager.UpdatePublicIPv4Assignments(id, requested, count, auto)
}
return lxcManager.UpdatePublicIPv4Assignments(id, requested, count, auto)
}
func updateIPv6ByRuntime(id int, requested []string, count int, auto bool) (*config.Container, error) {
c := config.FindContainer(id)
if c != nil && c.IsKVM() {
return kvmManager.UpdateIPv6Assignments(id, requested, count, auto)
}
return lxcManager.UpdateIPv6Assignments(id, requested, count, auto)
}
func usageByRuntime(id int) (map[string]interface{}, error) {
c := config.FindContainer(id)
if c != nil && c.IsKVM() {
@@ -104,12 +147,12 @@ func trafficByRuntime(id int) map[string]interface{} {
return lxcManager.GetTrafficInfo(id)
}
func createSnapshotByRuntime(id int, createdBy string, scheduled bool, rotateLimit int) (config.Snapshot, error) {
func createSnapshotByRuntime(id int, createdBy string, scheduled bool, rotateLimit int, storagePoolID ...string) (config.Snapshot, error) {
c := config.FindContainer(id)
if c != nil && c.IsKVM() {
return kvmManager.CreateSnapshot(id, createdBy, scheduled, rotateLimit)
return kvmManager.CreateSnapshot(id, createdBy, scheduled, rotateLimit, storagePoolID...)
}
return lxcManager.CreateSnapshot(id, createdBy, scheduled, rotateLimit)
return lxcManager.CreateSnapshot(id, createdBy, scheduled, rotateLimit, storagePoolID...)
}
func deleteSnapshotByRuntime(snapshotID string) error {
+165 -53
View File
@@ -49,15 +49,19 @@ type connEntry struct {
}
type trafficStats struct {
total int
totalSynSent int
destCounts map[string]int
destPorts map[string]map[int]int
portDestCounts map[int]map[string]int
portTotalCounts map[int]int
udpDestCounts map[int]map[string]int
udpTotalCounts map[int]int
synSentByDst map[string]int
total int
totalSynSent int
destCounts map[string]int
destPorts map[string]map[int]int
portDestCounts map[int]map[string]int
portTotalCounts map[int]int
udpDestCounts map[int]map[string]int
udpTotalCounts map[int]int
udpDestTotalCounts map[string]int
synSentByDst map[string]int
tcpSynDestPorts map[string]map[int]int
tcpSynPortDestCounts map[int]map[string]int
tcpSynPortTotalCounts map[int]int
}
var scanner *SecurityScanner
@@ -180,6 +184,12 @@ func (ss *SecurityScanner) monitorLoop() {
}
}
func (ss *SecurityScanner) alertCount() int {
ss.mu.Lock()
defer ss.mu.Unlock()
return len(ss.alerts)
}
func (ss *SecurityScanner) checkAllContainers() {
for _, c := range config.AppConfig.Containers {
if c.Status != "running" || c.IP == "" {
@@ -208,6 +218,7 @@ func (ss *SecurityScanner) checkContainer(name, ip string) {
return
}
alertBefore := ss.alertCount()
ss.detectPortScans(name, ip, stats)
ss.detectBruteForce(name, ip, stats)
ss.detectSpam(name, ip, stats)
@@ -216,17 +227,26 @@ func (ss *SecurityScanner) checkContainer(name, ip string) {
ss.detectMining(name, ip, stats)
ss.detectProxyAndTor(name, ip, stats)
ss.detectMalware(name, ip, stats)
// If new alerts were generated, snapshot the conntrack data for later retrieval.
if ss.alertCount() > alertBefore {
config.SaveConntrackSnapshot(ip, lines)
}
}
func newTrafficStats() *trafficStats {
return &trafficStats{
destCounts: make(map[string]int),
destPorts: make(map[string]map[int]int),
portDestCounts: make(map[int]map[string]int),
portTotalCounts: make(map[int]int),
udpDestCounts: make(map[int]map[string]int),
udpTotalCounts: make(map[int]int),
synSentByDst: make(map[string]int),
destCounts: make(map[string]int),
destPorts: make(map[string]map[int]int),
portDestCounts: make(map[int]map[string]int),
portTotalCounts: make(map[int]int),
udpDestCounts: make(map[int]map[string]int),
udpTotalCounts: make(map[int]int),
synSentByDst: make(map[string]int),
udpDestTotalCounts: make(map[string]int),
tcpSynDestPorts: make(map[string]map[int]int),
tcpSynPortDestCounts: make(map[int]map[string]int),
tcpSynPortTotalCounts: make(map[int]int),
}
}
@@ -252,52 +272,64 @@ func (ts *trafficStats) add(conn connEntry) {
}
ts.udpDestCounts[conn.dstPort][conn.dstIP]++
ts.udpTotalCounts[conn.dstPort]++
ts.udpDestTotalCounts[conn.dstIP]++
}
}
if conn.state == "SYN_SENT" {
if conn.proto == "tcp" && conn.state == "SYN_SENT" {
ts.totalSynSent++
ts.synSentByDst[conn.dstIP]++
if conn.dstPort > 0 {
if ts.tcpSynDestPorts[conn.dstIP] == nil {
ts.tcpSynDestPorts[conn.dstIP] = make(map[int]int)
}
ts.tcpSynDestPorts[conn.dstIP][conn.dstPort]++
if ts.tcpSynPortDestCounts[conn.dstPort] == nil {
ts.tcpSynPortDestCounts[conn.dstPort] = make(map[string]int)
}
ts.tcpSynPortDestCounts[conn.dstPort][conn.dstIP]++
ts.tcpSynPortTotalCounts[conn.dstPort]++
}
}
}
func (ss *SecurityScanner) detectPortScans(name, ip string, stats *trafficStats) {
for dstIP, portCounts := range stats.destPorts {
for dstIP, portCounts := range stats.tcpSynDestPorts {
uniquePorts := len(portCounts)
switch {
case uniquePorts >= 20:
case uniquePorts >= 25:
ss.addAlert(name, "port_scan", "high", ip, dstIP, 0,
fmt.Sprintf("端口扫描: 同一目标 %s 出现 %d 个不同目标端口", dstIP, uniquePorts),
fmt.Sprintf("端口扫描: 同一目标 %s 出现 %d 个不同 TCP 半开目标端口", dstIP, uniquePorts),
"")
case uniquePorts >= 8:
case uniquePorts >= 12:
ss.addAlert(name, "port_scan", "medium", ip, dstIP, 0,
fmt.Sprintf("可疑端口探测: 同一目标 %s 出现 %d 个不同目标端口", dstIP, uniquePorts),
fmt.Sprintf("可疑端口探测: 同一目标 %s 出现 %d 个不同 TCP 半开目标端口", dstIP, uniquePorts),
"")
}
}
for port, targets := range stats.portDestCounts {
for port, targets := range stats.tcpSynPortDestCounts {
uniqueTargets := len(targets)
if service, ok := bruteForcePorts[port]; ok {
if uniqueTargets >= 30 {
ss.addAlert(name, "brute_force", "critical", ip, "*", port,
fmt.Sprintf("横向爆破: 目标服务 %s(%d) 覆盖 %d 个不同 IP", service, port, uniqueTargets),
fmt.Sprintf("横向爆破: 目标服务 %s(%d) 出现 TCP 半开连接并覆盖 %d 个不同 IP", service, port, uniqueTargets),
"")
} else if uniqueTargets >= 10 {
} else if uniqueTargets >= 12 {
ss.addAlert(name, "brute_force", "high", ip, "*", port,
fmt.Sprintf("疑似横向爆破: 目标服务 %s(%d) 覆盖 %d 个不同 IP", service, port, uniqueTargets),
fmt.Sprintf("疑似横向爆破: 目标服务 %s(%d) 出现 TCP 半开连接并覆盖 %d 个不同 IP", service, port, uniqueTargets),
"")
}
continue
}
if uniqueTargets >= 40 {
if uniqueTargets >= 50 {
ss.addAlert(name, "horizontal_scan", "high", ip, "*", port,
fmt.Sprintf("横向扫描: 同一端口 %d 覆盖 %d 个不同目标", port, uniqueTargets),
fmt.Sprintf("横向扫描: 同一 TCP 端口 %d 出现半开连接并覆盖 %d 个不同目标", port, uniqueTargets),
"")
} else if uniqueTargets >= 15 {
} else if uniqueTargets >= 20 {
ss.addAlert(name, "horizontal_scan", "medium", ip, "*", port,
fmt.Sprintf("可疑横向探测: 同一端口 %d 覆盖 %d 个不同目标", port, uniqueTargets),
fmt.Sprintf("可疑横向探测: 同一 TCP 端口 %d 出现半开连接并覆盖 %d 个不同目标", port, uniqueTargets),
"")
}
}
@@ -311,13 +343,25 @@ func (ss *SecurityScanner) detectBruteForce(name, ip string, stats *trafficStats
continue
}
if count >= 20 {
synCount := 0
if ports := stats.tcpSynDestPorts[dstIP]; ports != nil {
synCount = ports[port]
}
if synCount >= 25 {
ss.addAlert(name, "brute_force", "critical", ip, dstIP, port,
fmt.Sprintf("暴力破解: %s(%d) 当前连接 %d", service, port, count),
fmt.Sprintf("暴力破解: %s(%d) 当前 TCP 半开连接 %d", service, port, synCount),
"")
} else if count >= 10 {
} else if synCount >= 12 {
ss.addAlert(name, "brute_force", "high", ip, dstIP, port,
fmt.Sprintf("疑似暴力破解: %s(%d) 当前连接 %d", service, port, count),
fmt.Sprintf("疑似暴力破解: %s(%d) 当前 TCP 半开连接 %d", service, port, synCount),
"")
} else if count >= 60 {
ss.addAlert(name, "brute_force", "critical", ip, dstIP, port,
fmt.Sprintf("暴力破解: %s(%d) 当前连接数 %d 条", service, port, count),
"")
} else if count >= 30 {
ss.addAlert(name, "brute_force", "high", ip, dstIP, port,
fmt.Sprintf("疑似暴力破解: %s(%d) 当前连接数 %d 条", service, port, count),
"")
}
}
@@ -344,30 +388,41 @@ func (ss *SecurityScanner) detectSpam(name, ip string, stats *trafficStats) {
func (ss *SecurityScanner) detectMassAbuse(name, ip string, stats *trafficStats) {
targets := len(stats.destCounts)
switch {
case targets >= 100:
case targets >= 120 && stats.total >= 600:
ss.addAlert(name, "ddos", "critical", ip, "*", 0,
fmt.Sprintf("大规模对外连接: 当前覆盖 %d 个不同目标", targets),
fmt.Sprintf("大规模对外连接: 当前 conntrack 出站记录 %d 条,覆盖 %d 个不同目标", stats.total, targets),
"")
case targets >= 35:
case targets >= 60 && stats.total >= 300:
ss.addAlert(name, "ddos", "high", ip, "*", 0,
fmt.Sprintf("大量对外连接: 当前覆盖 %d 个不同目标", targets),
fmt.Sprintf("大量对外连接: 当前 conntrack 出站记录 %d 条,覆盖 %d 个不同目标", stats.total, targets),
"")
}
synTargets := len(stats.synSentByDst)
switch {
case stats.total >= 500:
case stats.totalSynSent >= 250 || (synTargets >= 80 && stats.totalSynSent >= 160):
ss.addAlert(name, "ddos", "critical", ip, "*", 0,
fmt.Sprintf("异常大量连接: 当前 conntrack 出站记录 %d 条", stats.total),
fmt.Sprintf("大量半开连接: 当前 TCP SYN_SENT %d 条,覆盖 %d 个不同目标", stats.totalSynSent, synTargets),
"")
case stats.total >= 200:
case stats.totalSynSent >= 100 || (synTargets >= 35 && stats.totalSynSent >= 70):
ss.addAlert(name, "ddos", "high", ip, "*", 0,
fmt.Sprintf("连接: 当前 conntrack 出站记录 %d 条", stats.total),
fmt.Sprintf("可疑大量半开连接: 当前 TCP SYN_SENT %d 条,覆盖 %d 个不同目标", stats.totalSynSent, synTargets),
"")
}
if stats.totalSynSent >= 100 {
udpTargets := len(stats.udpDestTotalCounts)
udpTotal := 0
for _, count := range stats.udpTotalCounts {
udpTotal += count
}
switch {
case udpTargets >= 120 && udpTotal >= 300:
ss.addAlert(name, "ddos", "critical", ip, "*", 0,
fmt.Sprintf("大量半开连接: 当前 SYN_SENT %d 条", stats.totalSynSent),
fmt.Sprintf("UDP 大规模外发: 当前 UDP 连接 %d 条,覆盖 %d 个不同目标", udpTotal, udpTargets),
"")
case udpTargets >= 50 && udpTotal >= 120:
ss.addAlert(name, "ddos", "high", ip, "*", 0,
fmt.Sprintf("可疑 UDP 大规模外发: 当前 UDP 连接 %d 条,覆盖 %d 个不同目标", udpTotal, udpTargets),
"")
}
@@ -392,11 +447,18 @@ func (ss *SecurityScanner) detectReflectionAbuse(name, ip string, stats *traffic
continue
}
if targets >= 30 || total >= 100 {
criticalTargets, criticalTotal := 40, 120
highTargets, highTotal := 15, 45
if port == 53 {
criticalTargets, criticalTotal = 75, 300
highTargets, highTotal = 25, 100
}
if targets >= criticalTargets && total >= criticalTotal {
ss.addAlert(name, "reflection", "critical", ip, "*", port,
fmt.Sprintf("UDP 反射放大: %s(%d) 当前 UDP 连接 %d 条,覆盖 %d 个目标", service, port, total, targets),
"")
} else if targets >= 10 || total >= 30 {
} else if targets >= highTargets && total >= highTotal {
ss.addAlert(name, "reflection", "high", ip, "*", port,
fmt.Sprintf("疑似 UDP 反射放大: %s(%d) 当前 UDP 连接 %d 条,覆盖 %d 个目标", service, port, total, targets),
"")
@@ -633,6 +695,9 @@ func severityRank(severity string) int {
}
func autoShutdownAlertContainer(containerName, alertType, severity string) {
if !config.AppConfig.SecurityAutoShutdown {
return
}
c := config.FindContainerByName(containerName)
if c == nil || c.Status != "running" {
return
@@ -648,6 +713,24 @@ func autoShutdownAlertContainer(containerName, alertType, severity string) {
}
}
func clearSecurityPolicyBlocks() int {
cleared := 0
for i := range config.AppConfig.Containers {
c := &config.AppConfig.Containers[i]
if !c.PolicyBlocked || !isSecurityPolicyBlockReason(c.PolicyBlockedReason) {
continue
}
config.SetContainerPolicyBlock(c.ID, false, "")
config.AddAuditLog("security_policy_unblock", c.Name, "关闭安全告警自动关机后解除策略临时封禁", "system")
cleared++
}
return cleared
}
func isSecurityPolicyBlockReason(reason string) bool {
return strings.Contains(reason, "告警触发策略临时封禁")
}
// HandleSecurityAlerts returns all security alerts.
func HandleSecurityAlerts(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
@@ -687,9 +770,17 @@ func HandleSecuritySettings(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
return
}
cancelledTasks := 0
clearedBlocks := 0
if !req.AutoShutdown {
cancelledTasks = globalQueue.CancelPendingSecurityStops()
clearedBlocks = clearSecurityPolicyBlocks()
}
auditRequest(r, "security.settings", "auto_shutdown", fmt.Sprintf("auto_shutdown=%v", req.AutoShutdown), true, "")
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]bool{
"auto_shutdown": config.AppConfig.SecurityAutoShutdown,
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]interface{}{
"auto_shutdown": config.AppConfig.SecurityAutoShutdown,
"cancelled_tasks": cancelledTasks,
"cleared_blocks": clearedBlocks,
}})
default:
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
@@ -759,28 +850,49 @@ func HandleSecurityLogs(w http.ResponseWriter, r *http.Request) {
func getConnectionLogs(ip string) []map[string]interface{} {
logs := make([]map[string]interface{}, 0)
seen := map[string]bool{}
for _, line := range readConntrackLines(ip) {
parseLine := func(line string) map[string]interface{} {
srcIP := extractField(line, "src=")
dstIP := extractField(line, "dst=")
srcPort := extractField(line, "sport=")
dstPort := extractField(line, "dport=")
sPort, _ := strconv.Atoi(srcPort)
dPort, _ := strconv.Atoi(dstPort)
logs = append(logs, map[string]interface{}{
return map[string]interface{}{
"src_ip": srcIP,
"dst_ip": dstIP,
"src_port": sPort,
"dst_port": dPort,
"protocol": extractProtocol(line),
"state": extractConnState(line),
})
}
}
// First, load stored snapshots from database (persisted at alert time).
for _, line := range config.GetConntrackSnapshotLines(ip) {
if len(logs) >= 100 {
break
}
key := strings.TrimSpace(line)
if key == "" || seen[key] {
continue
}
seen[key] = true
logs = append(logs, parseLine(line))
}
// Then, merge live conntrack data (deduplicated).
for _, line := range readConntrackLines(ip) {
if len(logs) >= 100 {
break
}
key := strings.TrimSpace(line)
if key == "" || seen[key] {
continue
}
seen[key] = true
logs = append(logs, parseLine(line))
}
return logs
+148
View File
@@ -0,0 +1,148 @@
package api
import (
"fmt"
"testing"
"clicd/internal/config"
)
func TestDetectReflectionAbuseIgnoresSingleDNSResolver(t *testing.T) {
resetSecurityTestConfig()
stats := newTrafficStats()
for i := 0; i < 180; i++ {
stats.add(connEntry{
dstIP: "1.1.1.1",
dstPort: 53,
proto: "udp",
state: "UNREPLIED",
})
}
ss := newSecurityScanner()
ss.detectReflectionAbuse("ct-dns", "10.0.0.2", stats)
if len(ss.alerts) != 0 {
t.Fatalf("normal DNS queries to one resolver should not trigger reflection alert: %+v", ss.alerts)
}
}
func TestDetectReflectionAbuseFlagsWideDNSFanout(t *testing.T) {
resetSecurityTestConfig()
stats := newTrafficStats()
for i := 0; i < 120; i++ {
stats.add(connEntry{
dstIP: fmt.Sprintf("203.0.113.%d", i),
dstPort: 53,
proto: "udp",
state: "UNREPLIED",
})
}
ss := newSecurityScanner()
ss.detectReflectionAbuse("ct-dns", "10.0.0.2", stats)
if len(ss.alerts) != 1 {
t.Fatalf("expected one reflection alert, got %+v", ss.alerts)
}
if got := ss.alerts[0].Type; got != "reflection" {
t.Fatalf("expected reflection alert, got %q", got)
}
}
func TestDetectPortScansUsesHalfOpenConnections(t *testing.T) {
resetSecurityTestConfig()
established := newTrafficStats()
for port := 8000; port < 8020; port++ {
established.add(connEntry{
dstIP: "198.51.100.10",
dstPort: port,
proto: "tcp",
state: "ESTABLISHED",
})
}
ss := newSecurityScanner()
ss.detectPortScans("ct-web", "10.0.0.3", established)
if len(ss.alerts) != 0 {
t.Fatalf("established multi-port connections should not trigger port scan alert: %+v", ss.alerts)
}
halfOpen := newTrafficStats()
for port := 8000; port < 8012; port++ {
halfOpen.add(connEntry{
dstIP: "198.51.100.10",
dstPort: port,
proto: "tcp",
state: "SYN_SENT",
})
}
ss.detectPortScans("ct-web", "10.0.0.3", halfOpen)
if len(ss.alerts) != 1 {
t.Fatalf("expected one port scan alert, got %+v", ss.alerts)
}
if got := ss.alerts[0].Type; got != "port_scan" {
t.Fatalf("expected port_scan alert, got %q", got)
}
}
func TestCancelPendingSecurityStops(t *testing.T) {
resetSecurityTestConfig()
q := &TaskQueue{
tasks: map[string]*Task{},
}
securityTask := &Task{
ID: "task-1",
Type: TaskStop,
ContainerID: 1,
Status: "pending",
User: "system:security",
}
userTask := &Task{
ID: "task-2",
Type: TaskStop,
ContainerID: 2,
Status: "pending",
User: "admin",
}
runningSecurityTask := &Task{
ID: "task-3",
Type: TaskStop,
ContainerID: 3,
Status: "running",
User: "system:security",
}
q.tasks[securityTask.ID] = securityTask
q.tasks[userTask.ID] = userTask
q.tasks[runningSecurityTask.ID] = runningSecurityTask
q.opQueue = []*Task{securityTask, userTask, runningSecurityTask}
if got := q.CancelPendingSecurityStops(); got != 1 {
t.Fatalf("expected one pending security stop to be cancelled, got %d", got)
}
if _, ok := q.tasks[securityTask.ID]; ok {
t.Fatal("pending security stop task was not removed")
}
if _, ok := q.tasks[userTask.ID]; !ok {
t.Fatal("user stop task should not be removed")
}
if _, ok := q.tasks[runningSecurityTask.ID]; !ok {
t.Fatal("running security stop task should be left for worker-side skip")
}
if len(q.opQueue) != 2 {
t.Fatalf("expected op queue to keep two tasks, got %d", len(q.opQueue))
}
}
func resetSecurityTestConfig() {
config.AppConfig = &config.ClicdConfig{
Containers: []config.Container{},
AuditLogs: []config.AuditLog{},
Tasks: []config.SavedTask{},
}
}
+33
View File
@@ -2,6 +2,7 @@ package api
import (
"encoding/json"
"fmt"
"net/http"
"time"
@@ -48,6 +49,38 @@ func HandleLanguage(w http.ResponseWriter, r *http.Request) {
}
}
// HandleTaskQueueSettings returns or updates the global task concurrency limit.
func HandleTaskQueueSettings(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: globalQueue.Settings()})
case http.MethodPut, http.MethodPost:
var req struct {
Concurrency int `json:"concurrency"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
return
}
if req.Concurrency < 1 || req.Concurrency > config.MaxTaskConcurrency {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "任务并发数必须在 1 到 16 之间"})
return
}
previous := config.AppConfig.TaskConcurrency
config.AppConfig.TaskConcurrency = req.Concurrency
if err := config.SaveConfig(); err != nil {
config.AppConfig.TaskConcurrency = previous
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "保存任务队列设置失败"})
return
}
globalQueue.SetConcurrency(req.Concurrency)
auditRequest(r, "settings.task_queue", "task_concurrency", fmt.Sprintf("concurrency=%d", req.Concurrency), true, "")
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "任务队列设置已保存", Data: globalQueue.Settings()})
default:
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
}
}
// RecordLoginLog adds a login attempt to the log (persisted to config)
func RecordLoginLog(username, ip, userAgent string, success bool) {
config.AddLoginLog(username, ip, userAgent, success)
+22 -1
View File
@@ -2,6 +2,7 @@ package api
import (
"encoding/json"
"io"
"net/http"
"sort"
"strconv"
@@ -88,6 +89,20 @@ func listContainerSnapshots(w http.ResponseWriter, r *http.Request, containerID
func createContainerSnapshot(w http.ResponseWriter, r *http.Request, containerID int) {
user := requestUser(r)
var req struct {
StoragePoolID string `json:"storage_pool_id"`
}
if r.Body != nil {
if err := json.NewDecoder(r.Body).Decode(&req); err != nil && err != io.EOF {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
return
}
}
req.StoragePoolID = strings.TrimSpace(req.StoragePoolID)
if _, err := config.SelectStoragePoolForContent(config.StorageContentSnapshots, req.StoragePoolID, 0); err != nil {
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: err.Error()})
return
}
if isSubUserRequest(r) {
c := config.FindContainer(containerID)
limit := config.ContainerSnapshotLimit(c)
@@ -96,7 +111,7 @@ func createContainerSnapshot(w http.ResponseWriter, r *http.Request, containerID
return
}
}
snapshot, err := createSnapshotByRuntime(containerID, user, false, 0)
snapshot, err := createSnapshotByRuntime(containerID, user, false, 0, req.StoragePoolID)
if err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
return
@@ -159,6 +174,12 @@ func updateSnapshotSchedule(w http.ResponseWriter, r *http.Request, containerID
if req.Time == "" {
req.Time = "03:00"
}
if req.Enabled {
if _, err := config.SelectStoragePoolForContent(config.StorageContentSnapshots, "", 0); err != nil {
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: err.Error()})
return
}
}
user := requestUser(r)
c, err := setSnapshotScheduleByRuntime(containerID, req.Enabled, req.IntervalHours, req.Time, user)
if err != nil {
+498
View File
@@ -0,0 +1,498 @@
package api
import (
"encoding/json"
"fmt"
"net/http"
"os"
"os/exec"
pathpkg "path"
"path/filepath"
"strings"
"clicd/internal/config"
)
type storageInfoResponse struct {
Pools []storagePoolInfo `json:"pools"`
Disks []storageDiskInfo `json:"disks"`
ContentTypes []string `json:"content_types"`
}
type storagePoolInfo struct {
config.StoragePool
Available bool `json:"available"`
Exists bool `json:"exists"`
SizeBytes int64 `json:"size_bytes"`
UsedBytes int64 `json:"used_bytes"`
FreeBytes int64 `json:"free_bytes"`
ClicdUsedBytes int64 `json:"clicd_used_bytes"`
ContentUsage []storageContentUsage `json:"content_usage"`
Error string `json:"error,omitempty"`
}
type storageContentUsage struct {
ContentType string `json:"content_type"`
SizeBytes int64 `json:"size_bytes"`
}
type storageDiskInfo struct {
Name string `json:"name"`
Path string `json:"path"`
Type string `json:"type"`
FSType string `json:"fstype"`
MountPoint string `json:"mount_point"`
Model string `json:"model"`
SizeBytes int64 `json:"size_bytes"`
UsedBytes int64 `json:"used_bytes"`
FreeBytes int64 `json:"free_bytes"`
StoragePoolID string `json:"storage_pool_id,omitempty"`
StoragePath string `json:"storage_path,omitempty"`
ClicdUsedBytes int64 `json:"clicd_used_bytes"`
ContentUsage []storageContentUsage `json:"content_usage"`
}
func HandleStorage(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: buildStorageInfo()})
case http.MethodPut:
var req struct {
Pools []config.StoragePool `json:"pools"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
return
}
pools, err := normalizeStoragePoolsRequest(req.Pools)
if err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
return
}
for _, pool := range pools {
if err := os.MkdirAll(pool.Path, 0755); err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: fmt.Sprintf("Failed to create %s: %v", pool.Path, err)})
return
}
}
config.AppConfig.StoragePools = pools
if err := config.SaveConfig(); err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to save storage pools"})
return
}
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: buildStorageInfo()})
default:
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
}
}
func buildStorageInfo() storageInfoResponse {
disks := detectStorageDisks()
pools := make([]storagePoolInfo, 0, len(config.AppConfig.StoragePools))
for _, pool := range config.AppConfig.StoragePools {
info := storagePoolInfo{StoragePool: pool}
if filepath.Clean(pool.MountPoint) == string(os.PathSeparator) {
_ = os.MkdirAll(pool.Path, 0755)
}
if st, err := os.Stat(pool.Path); err == nil && st.IsDir() {
info.Exists = true
} else if err != nil {
info.Error = err.Error()
}
detectedMountPoint := bestMountPointForPath(pool.Path, disks)
if info.MountPoint == "" {
info.MountPoint = detectedMountPoint
}
if detectedMountPoint != "" && filepath.Clean(info.MountPoint) == filepath.Clean(detectedMountPoint) {
info.Available = info.Exists
info.SizeBytes, info.UsedBytes, info.FreeBytes = dfPath(pool.Path)
info.ContentUsage, info.ClicdUsedBytes = contentUsageForPool(pool.Path)
} else if info.Error == "" {
info.Error = "storage disk is not mounted"
}
pools = append(pools, info)
}
for i := range disks {
for _, pool := range pools {
if pool.MountPoint != disks[i].MountPoint {
continue
}
disks[i].ClicdUsedBytes += pool.ClicdUsedBytes
disks[i].ContentUsage = mergeContentUsage(disks[i].ContentUsage, pool.ContentUsage)
if disks[i].StoragePoolID == "" {
disks[i].StoragePoolID = pool.ID
disks[i].StoragePath = pool.Path
}
}
}
return storageInfoResponse{
Pools: pools,
Disks: disks,
ContentTypes: []string{
config.StorageContentLXC,
config.StorageContentKVM,
config.StorageContentImages,
config.StorageContentSnapshots,
config.StorageContentBackups,
},
}
}
func normalizeStoragePoolsRequest(items []config.StoragePool) ([]config.StoragePool, error) {
return normalizeStoragePoolsRequestWithDisks(items, detectStorageDisks())
}
func normalizeStoragePoolsRequestWithDisks(items []config.StoragePool, disks []storageDiskInfo) ([]config.StoragePool, error) {
if len(items) == 0 {
return nil, fmt.Errorf("at least one mounted storage disk configuration must be retained")
}
result := make([]config.StoragePool, 0, len(items))
seen := map[string]bool{}
defaultSeen := map[string]bool{}
for _, item := range items {
disk, managedPath, err := storageDiskForPoolRequest(item, disks)
if err != nil {
return nil, err
}
id, name := storagePoolIdentity(disk)
if seen[id] {
return nil, fmt.Errorf("duplicate storage disk: %s", disk.MountPoint)
}
seen[id] = true
contentTypes := normalizeStorageContentTypes(item.ContentTypes)
defaultContents := normalizeStorageContentTypes(item.DefaultContents)
allowed := map[string]bool{}
for _, content := range contentTypes {
allowed[content] = true
}
defaults := make([]string, 0, len(defaultContents))
for _, content := range defaultContents {
if !allowed[content] {
continue
}
if defaultSeen[content] {
return nil, fmt.Errorf("only one default storage disk is allowed for %s", content)
}
defaultSeen[content] = true
defaults = append(defaults, content)
}
result = append(result, config.StoragePool{
ID: id,
Name: name,
Path: managedPath,
MountPoint: disk.MountPoint,
ContentTypes: contentTypes,
DefaultContents: defaults,
Enabled: item.Enabled,
})
}
return result, nil
}
func storageDiskForPoolRequest(item config.StoragePool, disks []storageDiskInfo) (storageDiskInfo, string, error) {
requestedMount := filepath.Clean(strings.TrimSpace(item.MountPoint))
if requestedMount == "." {
requestedMount = ""
}
requestedPath := filepath.Clean(strings.TrimSpace(item.Path))
if requestedPath == "." {
requestedPath = ""
}
for _, disk := range disks {
mountPoint := filepath.Clean(disk.MountPoint)
managedPath := managedStoragePath(mountPoint)
mountMatches := requestedMount != "" && requestedMount == mountPoint
pathMatches := requestedPath != "" && requestedPath == managedPath
if !mountMatches && !pathMatches {
continue
}
if requestedMount != "" && !mountMatches {
return storageDiskInfo{}, "", fmt.Errorf("storage disk mount point has changed; refresh and try again")
}
if requestedPath != "" && !pathMatches {
return storageDiskInfo{}, "", fmt.Errorf("custom storage paths are not allowed; refresh and try again")
}
return disk, managedPath, nil
}
return storageDiskInfo{}, "", fmt.Errorf("storage disk is not mounted or is no longer available")
}
func storagePoolIdentity(disk storageDiskInfo) (string, string) {
mountPoint := filepath.Clean(disk.MountPoint)
if mountPoint == string(os.PathSeparator) {
return "disk-root", "system (/)"
}
baseName := filepath.Base(mountPoint)
if baseName == "" || baseName == "." || baseName == string(os.PathSeparator) {
baseName = strings.TrimSpace(disk.Name)
}
if baseName == "" {
baseName = "storage"
}
devicePath := strings.TrimSpace(disk.Path)
if devicePath == "" {
devicePath = strings.TrimSpace(disk.Name)
}
return "disk-" + storageID(baseName), fmt.Sprintf("%s (%s)", baseName, devicePath)
}
func managedStoragePath(mountPoint string) string {
if filepath.Clean(mountPoint) == string(os.PathSeparator) {
return filepath.Join(string(os.PathSeparator), "var", "lib", "clicd")
}
return filepath.Join(filepath.Clean(mountPoint), "clicd")
}
func normalizeStorageContentTypes(values []string) []string {
seen := map[string]bool{}
result := []string{}
for _, value := range values {
var next string
switch strings.ToLower(strings.TrimSpace(value)) {
case config.StorageContentLXC:
next = config.StorageContentLXC
case config.StorageContentKVM:
next = config.StorageContentKVM
case config.StorageContentImages:
next = config.StorageContentImages
case config.StorageContentSnapshots:
next = config.StorageContentSnapshots
case config.StorageContentBackups:
next = config.StorageContentBackups
default:
continue
}
if seen[next] {
continue
}
seen[next] = true
result = append(result, next)
}
return result
}
func storageID(name string) string {
id := strings.ToLower(strings.TrimSpace(name))
id = strings.NewReplacer(" ", "-", "_", "-", ".", "-", "/", "-").Replace(id)
id = strings.Trim(id, "-")
if id == "" {
return "storage"
}
return id
}
func detectStorageDisks() []storageDiskInfo {
type lsblkDevice struct {
Name string `json:"name"`
Path string `json:"path"`
Type string `json:"type"`
FSType string `json:"fstype"`
MountPoint string `json:"mountpoint"`
Model string `json:"model"`
Size int64 `json:"size"`
ReadOnly bool `json:"ro"`
Children []lsblkDevice `json:"children"`
}
var payload struct {
BlockDevices []lsblkDevice `json:"blockdevices"`
}
out, err := exec.Command("lsblk", "-J", "-b", "-o", "NAME,PATH,SIZE,TYPE,FSTYPE,MOUNTPOINT,MODEL,RO").Output()
if err != nil {
return nil
}
if err := json.Unmarshal(out, &payload); err != nil {
return nil
}
result := []storageDiskInfo{}
var walk func(lsblkDevice)
walk = func(dev lsblkDevice) {
info := storageDiskInfo{
Name: dev.Name,
Path: dev.Path,
Type: dev.Type,
FSType: dev.FSType,
MountPoint: dev.MountPoint,
Model: strings.TrimSpace(dev.Model),
SizeBytes: dev.Size,
}
if isUsableStorageMount(dev.Type, dev.FSType, dev.Path, dev.MountPoint, dev.ReadOnly) && !mountIsReadOnly(dev.MountPoint) {
info.SizeBytes, info.UsedBytes, info.FreeBytes = dfPath(dev.MountPoint)
result = append(result, info)
}
for _, child := range dev.Children {
walk(child)
}
}
for _, dev := range payload.BlockDevices {
walk(dev)
}
return result
}
func isUsableStorageMount(deviceType, fsType, devicePath, mountPoint string, readOnly bool) bool {
if readOnly || strings.TrimSpace(mountPoint) == "" || !strings.HasPrefix(mountPoint, "/") {
return false
}
deviceType = strings.ToLower(strings.TrimSpace(deviceType))
devicePath = strings.ToLower(strings.TrimSpace(devicePath))
if deviceType == "loop" || deviceType == "rom" || deviceType == "zram" || strings.HasPrefix(devicePath, "/dev/loop") {
return false
}
fsType = strings.ToLower(strings.TrimSpace(fsType))
unsupportedFileSystems := map[string]bool{
"": true,
"squashfs": true,
"iso9660": true,
"udf": true,
"swap": true,
"tmpfs": true,
"devtmpfs": true,
"overlay": true,
"proc": true,
"sysfs": true,
"cgroup": true,
"cgroup2": true,
"efivarfs": true,
"securityfs": true,
}
if unsupportedFileSystems[fsType] {
return false
}
mountPoint = pathpkg.Clean(mountPoint)
for _, reserved := range []string{"/snap", "/boot"} {
if mountPoint == reserved || strings.HasPrefix(mountPoint, reserved+"/") {
return false
}
}
return true
}
func mountIsReadOnly(mountPoint string) bool {
out, err := exec.Command("findmnt", "-n", "-o", "OPTIONS", "--target", mountPoint).Output()
if err != nil {
return false
}
for _, option := range strings.Split(strings.TrimSpace(string(out)), ",") {
if strings.TrimSpace(option) == "ro" {
return true
}
}
return false
}
func contentUsageForPool(poolPath string) ([]storageContentUsage, int64) {
mapping := map[string]string{
config.StorageContentLXC: "lxc",
config.StorageContentKVM: "kvm",
config.StorageContentImages: "images",
config.StorageContentSnapshots: "snapshots",
config.StorageContentBackups: "backups",
}
result := make([]storageContentUsage, 0, len(mapping))
var total int64
for _, content := range []string{
config.StorageContentLXC,
config.StorageContentKVM,
config.StorageContentImages,
config.StorageContentSnapshots,
config.StorageContentBackups,
} {
size := dirSizeBytes(filepath.Join(poolPath, mapping[content]))
result = append(result, storageContentUsage{ContentType: content, SizeBytes: size})
total += size
}
return result, total
}
func mergeContentUsage(current []storageContentUsage, next []storageContentUsage) []storageContentUsage {
sizes := map[string]int64{}
order := []string{}
for _, item := range append(current, next...) {
if _, ok := sizes[item.ContentType]; !ok {
order = append(order, item.ContentType)
}
sizes[item.ContentType] += item.SizeBytes
}
result := make([]storageContentUsage, 0, len(order))
for _, content := range order {
result = append(result, storageContentUsage{ContentType: content, SizeBytes: sizes[content]})
}
return result
}
func dirSizeBytes(path string) int64 {
if resolved, err := filepath.EvalSymlinks(path); err == nil {
path = resolved
}
// Count allocated blocks on this filesystem only. LXC rootfs directories can
// contain active mounts such as proc/sys; traversing them is slow and reports
// enormous virtual sizes that are not actually occupied by CLICD data.
out, err := exec.Command("du", "-skx", path).Output()
if err == nil {
fields := strings.Fields(string(out))
if len(fields) > 0 {
var sizeKB int64
if _, scanErr := fmt.Sscanf(fields[0], "%d", &sizeKB); scanErr == nil && sizeKB <= (1<<63-1)/1024 {
return sizeKB * 1024
}
}
}
var size int64
_ = filepath.WalkDir(path, func(_ string, d os.DirEntry, err error) error {
if err != nil || d.IsDir() {
return nil
}
if info, statErr := d.Info(); statErr == nil {
size += info.Size()
}
return nil
})
return size
}
func dfPath(path string) (size int64, used int64, free int64) {
out, err := exec.Command("df", "-B1", "-P", path).Output()
if err != nil {
return 0, 0, 0
}
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
if len(lines) < 2 {
return 0, 0, 0
}
fields := strings.Fields(lines[len(lines)-1])
if len(fields) < 6 {
return 0, 0, 0
}
fmt.Sscanf(fields[1], "%d", &size)
fmt.Sscanf(fields[2], "%d", &used)
fmt.Sscanf(fields[3], "%d", &free)
return size, used, free
}
func bestMountPointForPath(path string, disks []storageDiskInfo) string {
path = strings.ReplaceAll(path, "\\", "/")
path = pathpkg.Clean(path)
best := ""
for _, disk := range disks {
mp := pathpkg.Clean(strings.ReplaceAll(disk.MountPoint, "\\", "/"))
if disk.MountPoint == "" || mp == "." {
continue
}
matches := path == mp
if mp == "/" {
matches = pathpkg.IsAbs(path)
} else if strings.HasPrefix(path, mp+"/") {
matches = true
}
if matches {
if len(mp) > len(best) {
best = mp
}
}
}
return best
}
+121
View File
@@ -0,0 +1,121 @@
package api
import (
"os"
"path/filepath"
"runtime"
"testing"
"clicd/internal/config"
)
func TestIsUsableStorageMount(t *testing.T) {
tests := []struct {
name string
deviceType string
fsType string
devicePath string
mountPoint string
readOnly bool
wantUsable bool
}{
{name: "root partition", deviceType: "part", fsType: "ext4", devicePath: "/dev/sda2", mountPoint: "/", wantUsable: true},
{name: "mounted data disk", deviceType: "disk", fsType: "xfs", devicePath: "/dev/sdb", mountPoint: "/data", wantUsable: true},
{name: "snap loop", deviceType: "loop", fsType: "squashfs", devicePath: "/dev/loop0", mountPoint: "/snap/core20/2105", readOnly: true},
{name: "loop without ro flag", deviceType: "loop", fsType: "ext4", devicePath: "/dev/loop7", mountPoint: "/mnt/loop"},
{name: "read only disk", deviceType: "part", fsType: "ext4", devicePath: "/dev/sdc1", mountPoint: "/archive", readOnly: true},
{name: "optical image", deviceType: "rom", fsType: "iso9660", devicePath: "/dev/sr0", mountPoint: "/media/cdrom"},
{name: "efi partition", deviceType: "part", fsType: "vfat", devicePath: "/dev/sda1", mountPoint: "/boot/efi"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := isUsableStorageMount(tt.deviceType, tt.fsType, tt.devicePath, tt.mountPoint, tt.readOnly)
if got != tt.wantUsable {
t.Fatalf("isUsableStorageMount() = %v, want %v", got, tt.wantUsable)
}
})
}
}
func TestBestMountPointForPath(t *testing.T) {
disks := []storageDiskInfo{
{Path: "/dev/sda2", MountPoint: "/"},
{Path: "/dev/sdb1", MountPoint: "/mnt/clicd-data"},
}
tests := []struct {
path string
want string
}{
{path: "/var/lib/clicd", want: "/"},
{path: "/mnt/clicd-data/clicd", want: "/mnt/clicd-data"},
{path: "/mnt/clicd-data", want: "/mnt/clicd-data"},
}
for _, tt := range tests {
if got := bestMountPointForPath(tt.path, disks); got != tt.want {
t.Fatalf("bestMountPointForPath(%q) = %q, want %q", tt.path, got, tt.want)
}
}
}
func TestNormalizeStoragePoolsUsesServerManagedPath(t *testing.T) {
disks := []storageDiskInfo{
{Path: "/dev/sda2", MountPoint: "/"},
{Path: "/dev/sdb1", MountPoint: "/mnt/data"},
}
items := []config.StoragePool{{
ID: "disk-data",
Name: "data",
Path: "/mnt/data/clicd",
MountPoint: "/mnt/data",
ContentTypes: []string{config.StorageContentLXC},
DefaultContents: []string{config.StorageContentLXC},
Enabled: true,
}}
pools, err := normalizeStoragePoolsRequestWithDisks(items, disks)
if err != nil {
t.Fatal(err)
}
wantPath := filepath.Join(filepath.Clean("/mnt/data"), "clicd")
if len(pools) != 1 || pools[0].ID != "disk-data" || pools[0].Name != "data (/dev/sdb1)" || pools[0].Path != wantPath || pools[0].MountPoint != "/mnt/data" {
t.Fatalf("unexpected normalized pools: %#v", pools)
}
}
func TestNormalizeStoragePoolsRejectsUncontrolledPath(t *testing.T) {
disks := []storageDiskInfo{{Path: "/dev/sdb1", MountPoint: "/mnt/data"}}
for _, path := range []string{"/etc", "/mnt/data/clicd/../../etc", "/mnt/data/other"} {
_, err := normalizeStoragePoolsRequestWithDisks([]config.StoragePool{{
ID: "disk-data",
Name: "data",
Path: path,
MountPoint: "/mnt/data",
Enabled: true,
}}, disks)
if err == nil {
t.Fatalf("path %q was accepted", path)
}
}
}
func TestDirSizeBytesUsesAllocatedBlocks(t *testing.T) {
if runtime.GOOS != "linux" {
t.Skip("allocated-block behavior is provided by the Linux du command")
}
dir := t.TempDir()
file, err := os.Create(filepath.Join(dir, "sparse.img"))
if err != nil {
t.Fatal(err)
}
if err := file.Truncate(1 << 30); err != nil {
file.Close()
t.Fatal(err)
}
if err := file.Close(); err != nil {
t.Fatal(err)
}
if got := dirSizeBytes(dir); got >= 128<<20 {
t.Fatalf("dirSizeBytes() = %d, expected allocated size instead of 1 GiB apparent size", got)
}
}
+242 -44
View File
@@ -22,24 +22,30 @@ func generateRandomStr(length int) string {
}
type subUserResponse struct {
ID string `json:"id"`
Username string `json:"username"`
Password string `json:"password,omitempty"`
ContainerNames []string `json:"container_names"`
ContainerUUIDs []string `json:"container_uuids,omitempty"`
AccessCode string `json:"access_code"`
CreatedAt string `json:"created_at"`
ID string `json:"id"`
Username string `json:"username"`
Password string `json:"password,omitempty"`
ContainerNames []string `json:"container_names"`
ContainerUUIDs []string `json:"container_uuids,omitempty"`
AllowedImageIDs []string `json:"allowed_image_ids,omitempty"`
ImageLimitConfigured bool `json:"image_limit_configured,omitempty"`
CurrentImageIDs []string `json:"current_image_ids,omitempty"`
AccessCode string `json:"access_code"`
CreatedAt string `json:"created_at"`
}
func newSubUserResponse(su config.SubUser, password string) subUserResponse {
return subUserResponse{
ID: su.ID,
Username: su.Username,
Password: password,
ContainerNames: su.ContainerNames,
ContainerUUIDs: su.ContainerUUIDs,
AccessCode: su.AccessCode,
CreatedAt: su.CreatedAt,
ID: su.ID,
Username: su.Username,
Password: password,
ContainerNames: su.ContainerNames,
ContainerUUIDs: su.ContainerUUIDs,
AllowedImageIDs: effectiveSubUserAllowedImageIDs(&su),
ImageLimitConfigured: su.ImageLimitConfigured,
CurrentImageIDs: subUserCurrentImageIDs(&su),
AccessCode: su.AccessCode,
CreatedAt: su.CreatedAt,
}
}
@@ -94,6 +100,10 @@ func HandleSubUserCreate(w http.ResponseWriter, r *http.Request) {
}
su.ContainerNames = appendUniqueString(su.ContainerNames, containerName)
su.ContainerUUIDs = appendUniqueString(su.ContainerUUIDs, c.UUID)
if !su.ImageLimitConfigured && len(su.AllowedImageIDs) == 0 {
su.AllowedImageIDs = effectiveContainerAllowedImageIDs(c)
su.ImageLimitConfigured = true
}
config.SaveConfig()
jsonResponse(w, http.StatusOK, APIResponse{
Success: true,
@@ -114,14 +124,16 @@ func HandleSubUserCreate(w http.ResponseWriter, r *http.Request) {
accessCode := generateRandomStr(8)
subUser := config.SubUser{
ID: "sub-" + generateRandomStr(8),
Username: username,
Password: password,
PassHash: string(hash),
ContainerNames: []string{containerName},
ContainerUUIDs: []string{c.UUID},
AccessCode: accessCode,
CreatedAt: time.Now().Format("2006-01-02 15:04:05"),
ID: "sub-" + generateRandomStr(8),
Username: username,
Password: password,
PassHash: string(hash),
ContainerNames: []string{containerName},
ContainerUUIDs: []string{c.UUID},
AllowedImageIDs: effectiveContainerAllowedImageIDs(c),
ImageLimitConfigured: true,
AccessCode: accessCode,
CreatedAt: time.Now().Format("2006-01-02 15:04:05"),
}
config.AppConfig.SubUsers = append(config.AppConfig.SubUsers, subUser)
@@ -306,6 +318,155 @@ func requestAllowedContainers(r *http.Request) (subUserAccess, bool) {
return subUserAllowedContainers(r)
}
func subUserFromRequest(r *http.Request) *config.SubUser {
username := ""
if ctx, ok := authContextFromRequest(r); ok && ctx.Type == authTypeSubUser {
username = ctx.Username
}
if username == "" {
if claims, ok := claimsFromRequest(r); ok {
username, _ = claims["sub_user"].(string)
}
}
if username == "" {
return nil
}
for i := range config.AppConfig.SubUsers {
if config.AppConfig.SubUsers[i].Username == username {
return &config.AppConfig.SubUsers[i]
}
}
return nil
}
func normalizeAllowedImageIDs(ids []string) ([]string, error) {
seen := map[string]bool{}
result := make([]string, 0, len(ids))
for _, id := range ids {
id = strings.TrimSpace(id)
if id == "" || seen[id] {
continue
}
if !imageTemplateExists(id) {
return nil, fmt.Errorf("unknown image template: %s", id)
}
seen[id] = true
result = append(result, id)
}
return result, nil
}
func isTemplateAllowedForRequest(r *http.Request, c *config.Container, templateID string) bool {
if !isSubUserRequest(r) {
return true
}
return isImageAllowedForSubUser(subUserFromRequest(r), c, templateID)
}
func isImageAllowedForSubUser(su *config.SubUser, c *config.Container, templateID string) bool {
if su == nil || strings.TrimSpace(templateID) == "" {
return false
}
for _, id := range effectiveSubUserAllowedImageIDs(su) {
if id == templateID {
return true
}
}
return false
}
func effectiveContainerAllowedImageIDs(c *config.Container) []string {
if c == nil {
return nil
}
if c.ImageLimitConfigured || len(c.AllowedImageIDs) > 0 {
return cleanImageIDList(c.AllowedImageIDs)
}
if c.Template != "" {
return []string{c.Template}
}
return nil
}
func effectiveSubUserAllowedImageIDs(su *config.SubUser) []string {
if su == nil {
return nil
}
if su.ImageLimitConfigured || len(su.AllowedImageIDs) > 0 {
return cleanImageIDList(su.AllowedImageIDs)
}
result := []string{}
seen := map[string]bool{}
for _, c := range subUserAssignedContainers(su) {
for _, id := range effectiveContainerAllowedImageIDs(c) {
if id != "" && !seen[id] {
seen[id] = true
result = append(result, id)
}
}
}
return result
}
func cleanImageIDList(ids []string) []string {
result := make([]string, 0, len(ids))
seen := map[string]bool{}
for _, id := range ids {
id = strings.TrimSpace(id)
if id == "" || seen[id] {
continue
}
seen[id] = true
result = append(result, id)
}
return result
}
func subUserCurrentImageIDs(su *config.SubUser) []string {
seen := map[string]bool{}
result := []string{}
for _, c := range subUserAssignedContainers(su) {
if c.Template != "" && !seen[c.Template] {
seen[c.Template] = true
result = append(result, c.Template)
}
}
return result
}
func subUserAssignedContainers(su *config.SubUser) []*config.Container {
if su == nil {
return nil
}
result := []*config.Container{}
seen := map[string]bool{}
for _, uuid := range su.ContainerUUIDs {
if c := config.FindContainerByUUID(uuid); c != nil {
key := c.UUID
if key == "" {
key = c.Name
}
if !seen[key] {
seen[key] = true
result = append(result, c)
}
}
}
for _, name := range su.ContainerNames {
if c := config.FindContainerByName(name); c != nil {
key := c.UUID
if key == "" {
key = c.Name
}
if !seen[key] {
seen[key] = true
result = append(result, c)
}
}
}
return result
}
func isAccessRestrictedRequest(r *http.Request) bool {
_, restricted := requestAllowedContainers(r)
return restricted
@@ -373,6 +534,15 @@ func SubUserMiddleware(next http.HandlerFunc) http.HandlerFunc {
return
}
imagesEnabledPath := "/api/images/enabled"
if strings.HasPrefix(path, "/api/v1/") {
imagesEnabledPath = "/api/v1/images/enabled"
}
if path == imagesEnabledPath && r.Method == http.MethodGet {
next(w, r)
return
}
if path == containerListPath {
if r.Method != http.MethodGet {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Sub-users cannot create containers"})
@@ -476,7 +646,7 @@ func isSubUserBlockedAction(action string, method string) bool {
return method != http.MethodGet
}
switch action {
case "usage", "traffic":
case "usage", "traffic", "history":
return method != http.MethodGet
default:
return true
@@ -495,7 +665,7 @@ func isSubUserContainerActionAllowed(action string, method string) bool {
return method == http.MethodGet
}
switch {
case action == "usage" || action == "traffic" || action == "random-port":
case action == "usage" || action == "traffic" || action == "history" || action == "random-port":
return method == http.MethodGet
case action == "snapshots":
return method == http.MethodGet || method == http.MethodPost
@@ -503,7 +673,7 @@ func isSubUserContainerActionAllowed(action string, method string) bool {
return method == http.MethodPost
case strings.HasPrefix(action, "snapshots/"):
return method == http.MethodDelete || method == http.MethodPost
case action == "start" || action == "stop" || action == "restart" || action == "reinstall":
case action == "start" || action == "stop" || action == "restart" || action == "reinstall" || action == "reset-password":
return method == http.MethodPost
case strings.HasPrefix(action, "port-mappings/"):
return method == http.MethodPut
@@ -571,18 +741,21 @@ func splitBy(s, sep string) []string {
// SubUserListItem is the enriched sub-user info returned by the list API
type SubUserListItem struct {
ID string `json:"id"`
Username string `json:"username"`
ContainerNames []string `json:"container_names"`
ContainerUUIDs []string `json:"container_uuids"`
ContainerName string `json:"container_name"`
ContainerUUID string `json:"container_uuid"`
AccessCode string `json:"access_code"`
Password string `json:"password,omitempty"`
CreatedAt string `json:"created_at"`
LastLogin string `json:"last_login"`
LastLoginIP string `json:"last_login_ip"`
LastLoginUA string `json:"last_login_ua"`
ID string `json:"id"`
Username string `json:"username"`
ContainerNames []string `json:"container_names"`
ContainerUUIDs []string `json:"container_uuids"`
AllowedImageIDs []string `json:"allowed_image_ids"`
ImageLimitConfigured bool `json:"image_limit_configured"`
CurrentImageIDs []string `json:"current_image_ids"`
ContainerName string `json:"container_name"`
ContainerUUID string `json:"container_uuid"`
AccessCode string `json:"access_code"`
Password string `json:"password,omitempty"`
CreatedAt string `json:"created_at"`
LastLogin string `json:"last_login"`
LastLoginIP string `json:"last_login_ip"`
LastLoginUA string `json:"last_login_ua"`
}
// HandleSubUserList returns the list of all sub-users with container info
@@ -598,13 +771,16 @@ func HandleSubUserList(w http.ResponseWriter, r *http.Request) {
result := make([]SubUserListItem, 0, len(config.AppConfig.SubUsers))
for _, su := range config.AppConfig.SubUsers {
item := SubUserListItem{
ID: su.ID,
Username: su.Username,
ContainerNames: su.ContainerNames,
ContainerUUIDs: su.ContainerUUIDs,
AccessCode: su.AccessCode,
Password: su.Password,
CreatedAt: su.CreatedAt,
ID: su.ID,
Username: su.Username,
ContainerNames: su.ContainerNames,
ContainerUUIDs: su.ContainerUUIDs,
AllowedImageIDs: effectiveSubUserAllowedImageIDs(&su),
ImageLimitConfigured: su.ImageLimitConfigured,
CurrentImageIDs: subUserCurrentImageIDs(&su),
AccessCode: su.AccessCode,
Password: su.Password,
CreatedAt: su.CreatedAt,
}
// Resolve container name from first active UUID
@@ -702,6 +878,28 @@ func HandleSubUserAction(w http.ResponseWriter, r *http.Request) {
logs := filterSubUserLoginLogs(target.Username)
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: logs})
case action == "images" && r.Method == http.MethodPut:
if !requireScope(w, r, "subuser:update") {
return
}
var req struct {
AllowedImageIDs []string `json:"allowed_image_ids"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
return
}
ids, err := normalizeAllowedImageIDs(req.AllowedImageIDs)
if err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
return
}
target.AllowedImageIDs = ids
target.ImageLimitConfigured = true
target.TokenVersion++
config.SaveConfig()
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: newSubUserResponse(*target, target.Password)})
default:
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Action not found"})
}
+510 -162
View File
@@ -30,6 +30,8 @@ type Task struct {
ContainerName string `json:"container_name"`
Status string `json:"status"`
Error string `json:"error,omitempty"`
Stage string `json:"stage,omitempty"`
StageDetail string `json:"stage_detail,omitempty"`
CreatedAt string `json:"created_at"`
TemplateID string `json:"template_id,omitempty"`
Config lxc.ContainerConfig `json:"config,omitempty"`
@@ -37,30 +39,74 @@ type Task struct {
User string `json:"user,omitempty"` // who created this task
IP string `json:"ip,omitempty"`
UserAgent string `json:"user_agent,omitempty"`
activeKey string
}
type TaskQueue struct {
mu sync.Mutex
createQueue []*Task
opQueue []*Task
tasks map[string]*Task
nextID int
createCond *sync.Cond
opCond *sync.Cond
stop chan struct{}
mu sync.Mutex
createQueue []*Task
opQueue []*Task
tasks map[string]*Task
nextID int
createCond *sync.Cond
opCond *sync.Cond
maxConcurrency int
activeTasks int
activeTargets map[string]bool
stop chan struct{}
}
type TaskQueueSettings struct {
Concurrency int `json:"concurrency"`
Active int `json:"active"`
Pending int `json:"pending"`
}
var globalQueue *TaskQueue
func init() {
globalQueue = &TaskQueue{
tasks: make(map[string]*Task),
stop: make(chan struct{}),
globalQueue = newTaskQueue(config.DefaultTaskConcurrency)
go globalQueue.createDispatcher()
go globalQueue.opDispatcher()
}
func newTaskQueue(concurrency int) *TaskQueue {
q := &TaskQueue{
tasks: make(map[string]*Task),
maxConcurrency: config.NormalizeTaskConcurrency(concurrency),
activeTargets: make(map[string]bool),
stop: make(chan struct{}),
}
globalQueue.createCond = sync.NewCond(&globalQueue.mu)
globalQueue.opCond = sync.NewCond(&globalQueue.mu)
go globalQueue.createWorker()
go globalQueue.opWorker()
q.createCond = sync.NewCond(&q.mu)
q.opCond = sync.NewCond(&q.mu)
return q
}
func ConfigureTaskQueue(concurrency int) {
globalQueue.SetConcurrency(concurrency)
}
func (q *TaskQueue) SetConcurrency(concurrency int) {
q.mu.Lock()
q.maxConcurrency = config.NormalizeTaskConcurrency(concurrency)
q.createCond.Broadcast()
q.opCond.Broadcast()
q.mu.Unlock()
}
func (q *TaskQueue) Settings() TaskQueueSettings {
q.mu.Lock()
defer q.mu.Unlock()
return TaskQueueSettings{
Concurrency: q.maxConcurrency,
Active: q.activeTasks,
Pending: len(q.createQueue) + len(q.opQueue),
}
}
func (q *TaskQueue) signalDispatchers() {
q.createCond.Broadcast()
q.opCond.Broadcast()
}
func (q *TaskQueue) enqueueTask(task *Task) {
@@ -75,6 +121,10 @@ func (q *TaskQueue) enqueueTask(task *Task) {
}
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()
defer q.mu.Unlock()
@@ -86,11 +136,17 @@ func (q *TaskQueue) Enqueue(containerID int, containerName string, taskType Task
ContainerID: containerID,
ContainerName: containerName,
Status: "pending",
Stage: "queued",
StageDetail: "排队等待",
CreatedAt: time.Now().Format("2006-01-02 15:04:05"),
TemplateID: templateID,
User: user,
IP: ip,
UserAgent: userAgent,
}
if cfg != nil {
task.Config = *cfg
task.Config.NormalizeResourceAliases()
}
q.enqueueTask(task)
q.persistTasks()
@@ -155,6 +211,7 @@ func (q *TaskQueue) enqueueBatchCreateList(configs []lxc.ContainerConfig, user s
var result []string
for _, cfg := range configs {
cfgCopy := cfg
cfgCopy.NormalizeResourceAliases()
id := q.nextID
q.nextID++
task := &Task{
@@ -163,6 +220,8 @@ func (q *TaskQueue) enqueueBatchCreateList(configs []lxc.ContainerConfig, user s
ContainerID: 0,
ContainerName: cfgCopy.Name,
Status: "pending",
Stage: "queued",
StageDetail: "排队等待",
CreatedAt: time.Now().Format("2006-01-02 15:04:05"),
Config: cfgCopy,
User: user,
@@ -193,6 +252,8 @@ func (q *TaskQueue) enqueueSingleWithAudit(containerID int, containerName string
ContainerID: containerID,
ContainerName: containerName,
Status: "pending",
Stage: "queued",
StageDetail: "排队等待",
CreatedAt: time.Now().Format("2006-01-02 15:04:05"),
TemplateID: templateID,
User: user,
@@ -204,6 +265,10 @@ func (q *TaskQueue) enqueueSingleWithAudit(containerID int, containerName string
}
func (q *TaskQueue) EnqueueSecurityStop(containerID int, containerName string) (string, bool) {
if !config.AppConfig.SecurityAutoShutdown {
return "", false
}
q.mu.Lock()
defer q.mu.Unlock()
@@ -221,160 +286,283 @@ func (q *TaskQueue) EnqueueSecurityStop(containerID int, containerName string) (
return taskID, true
}
// createWorker handles TaskCreate: lxc-create, resource setup, start, and SSH init.
// If a restored task already has a same-name container in config, it resumes
// initialization instead of creating another ct-{id}.
func (q *TaskQueue) createWorker() {
for {
q.mu.Lock()
for len(q.createQueue) == 0 {
q.createCond.Wait()
}
task := q.createQueue[0]
q.createQueue = q.createQueue[1:]
task.Status = "running"
q.mu.Unlock()
func (q *TaskQueue) CancelPendingSecurityStops() int {
q.mu.Lock()
defer q.mu.Unlock()
createdByTask := false
if task.Config.Name == "" {
task.Config.Name = task.ContainerName
}
if task.Config.Name == "" {
task.Status = "failed"
task.Error = "container name is required"
config.AddAuditLog(string(task.Type), task.ContainerName, "failed: "+task.Error, "admin")
q.mu.Lock()
q.persistTasks()
q.mu.Unlock()
cancelled := 0
newOpQueue := make([]*Task, 0, len(q.opQueue))
for _, task := range q.opQueue {
if isSecurityStopTask(task) && task.Status == "pending" {
delete(q.tasks, task.ID)
cancelled++
continue
}
c := config.FindContainerByName(task.Config.Name)
if c == nil {
// 1) Download image + apply limits (lxc-create)
err := createByRuntime(task.Config)
if err != nil {
task.Status = "failed"
task.Error = err.Error()
config.AddAuditLog(string(task.Type), task.Config.Name, "失败: "+err.Error(), "admin")
q.mu.Lock()
q.persistTasks()
q.mu.Unlock()
continue
}
createdByTask = true
newOpQueue = append(newOpQueue, task)
}
q.opQueue = newOpQueue
// 2) Find created container by name
c = config.FindContainerByName(task.Config.Name)
if c == nil {
task.Status = "failed"
task.Error = "created but not found in config"
config.AddAuditLog(string(task.Type), task.Config.Name, "失败: "+task.Error, "admin")
q.mu.Lock()
q.persistTasks()
q.mu.Unlock()
continue
}
for id, task := range q.tasks {
if isSecurityStopTask(task) && task.Status == "pending" {
delete(q.tasks, id)
cancelled++
}
task.ContainerID = c.ID
task.ContainerName = c.Name
// 3) Start + initialize SSH/network in the same worker.
// If init fails, destroy the container so no dead entry remains.
startErr := startByRuntime(c.ID)
if startErr != nil {
if createdByTask {
_ = destroyByRuntime(c.ID)
}
task.Status = "failed"
task.Error = startErr.Error()
config.AddAuditLog(string(task.Type), task.ContainerName, "初始化失败: "+startErr.Error(), "admin")
} else {
task.Status = "done"
config.AddAuditLog(string(task.Type), task.ContainerName, "成功", "admin")
}
q.mu.Lock()
}
if cancelled > 0 {
q.persistTasks()
q.mu.Unlock()
}
return cancelled
}
// The two dispatchers keep long-running creates from blocking power operations,
// while sharing one global concurrency budget.
func (q *TaskQueue) createDispatcher() {
for {
task := q.takeNextTask(true)
go q.runCreateTask(task)
}
}
// opWorker handles all non-create tasks (start, stop, restart, delete, reinstall)
// including the follow-up initialization after a create succeeds.
func (q *TaskQueue) opWorker() {
func (q *TaskQueue) opDispatcher() {
for {
q.mu.Lock()
for len(q.opQueue) == 0 {
q.opCond.Wait()
}
task := q.opQueue[0]
q.opQueue = q.opQueue[1:]
task.Status = "running"
q.mu.Unlock()
task := q.takeNextTask(false)
go q.runOperationTask(task)
}
}
var err error
err = resolveTaskContainer(task)
// Block operations on expired or traffic-exceeded containers (except stop/delete)
if err == nil && (task.Type == TaskStart || task.Type == TaskRestart || task.Type == TaskReinstall) {
c := config.FindContainer(task.ContainerID)
if c != nil {
if lxc.IsExpired(*c) {
err = fmt.Errorf("容器已到期,不允许此操作")
} else if lxc.IsTrafficExceeded(*c) {
err = fmt.Errorf("容器流量已超限,不允许此操作")
func (q *TaskQueue) takeNextTask(create bool) *Task {
q.mu.Lock()
defer q.mu.Unlock()
cond := q.opCond
if create {
cond = q.createCond
}
for {
queue := q.opQueue
if create {
queue = q.createQueue
}
if q.activeTasks < q.maxConcurrency {
if index := runnableTaskIndex(queue, q.activeTargets); index >= 0 {
task := queue[index]
queue = append(queue[:index], queue[index+1:]...)
if create {
q.createQueue = queue
} else {
q.opQueue = queue
}
task.Status = "running"
task.Error = ""
task.Stage = "preparing"
task.StageDetail = "准备初始化环境"
task.activeKey = taskConcurrencyKey(task)
q.activeTargets[task.activeKey] = true
q.activeTasks++
q.persistTasks()
return task
}
}
if err == nil {
switch task.Type {
case TaskStart:
err = startByRuntime(task.ContainerID)
case TaskStop:
err = stopByRuntime(task.ContainerID)
case TaskRestart:
err = restartByRuntime(task.ContainerID)
case TaskDelete:
err = destroyByRuntime(task.ContainerID)
if err == nil {
time.Sleep(1 * time.Second)
if config.FindContainer(task.ContainerID) != nil {
err = fmt.Errorf("container still exists after delete: %d", task.ContainerID)
}
cond.Wait()
}
}
func runnableTaskIndex(queue []*Task, activeTargets map[string]bool) int {
for index, task := range queue {
if !activeTargets[taskConcurrencyKey(task)] {
return index
}
}
return -1
}
func taskConcurrencyKey(task *Task) string {
if task == nil {
return "task:nil"
}
name := strings.TrimSpace(task.ContainerName)
if name == "" {
name = strings.TrimSpace(task.Config.Name)
}
if name != "" {
return "name:" + strings.ToLower(name)
}
if task.ContainerID > 0 {
return fmt.Sprintf("id:%d", task.ContainerID)
}
return "task:" + task.ID
}
func (q *TaskQueue) finishTask(task *Task, status string, taskErr error) {
q.mu.Lock()
task.Status = status
if taskErr != nil {
task.Error = taskErr.Error()
if task.Type == TaskCreate {
task.Stage = "failed"
task.StageDetail = "初始化失败"
}
} else {
task.Error = ""
if task.Type == TaskCreate {
task.Stage = "completed"
task.StageDetail = "初始化完成"
}
}
if task.activeKey != "" {
delete(q.activeTargets, task.activeKey)
task.activeKey = ""
}
if q.activeTasks > 0 {
q.activeTasks--
}
q.persistTasks()
q.signalDispatchers()
q.mu.Unlock()
}
func (q *TaskQueue) updateTaskStage(task *Task, stage, detail string) {
q.mu.Lock()
task.Stage = stage
task.StageDetail = detail
q.mu.Unlock()
}
// runCreateTask handles lxc-create, resource setup, start, and SSH init. A
// restored task resumes initialization when the same-name container exists.
func (q *TaskQueue) runCreateTask(task *Task) {
q.mu.Lock()
createdByTask := false
if task.Config.Name == "" {
task.Config.Name = task.ContainerName
}
task.Config.NormalizeResourceAliases()
cfg := task.Config
q.mu.Unlock()
cfg.Progress = func(stage, detail string) {
q.updateTaskStage(task, stage, detail)
}
if cfg.Name == "" {
err := fmt.Errorf("container name is required")
config.AddAuditLog(string(task.Type), task.ContainerName, "failed: "+err.Error(), "admin")
q.finishTask(task, "failed", err)
return
}
c := config.FindContainerByName(cfg.Name)
if c == nil {
if err := createByRuntime(cfg); err != nil {
config.AddAuditLog(string(task.Type), cfg.Name, "失败: "+err.Error(), "admin")
q.finishTask(task, "failed", err)
return
}
createdByTask = true
c = config.FindContainerByName(cfg.Name)
if c == nil {
err := fmt.Errorf("created but not found in config")
config.AddAuditLog(string(task.Type), task.Config.Name, "失败: "+err.Error(), "admin")
q.finishTask(task, "failed", err)
return
}
} else {
lxc.ReleaseQueuedCreateNATPorts(cfg.Name)
}
q.mu.Lock()
task.ContainerID = c.ID
task.ContainerName = c.Name
q.mu.Unlock()
startDetail := "启动容器并等待网络就绪"
if strings.EqualFold(cfg.Virtualization, config.VirtualizationKVM) {
startDetail = "启动虚拟机并等待网络就绪"
}
q.updateTaskStage(task, "starting", startDetail)
if err := startByRuntime(c.ID); err != nil {
if createdByTask {
_ = destroyByRuntime(c.ID)
}
config.AddAuditLog(string(task.Type), task.ContainerName, "初始化失败: "+err.Error(), "admin")
q.finishTask(task, "failed", err)
return
}
config.AddAuditLog(string(task.Type), task.ContainerName, "成功", "admin")
q.finishTask(task, "done", nil)
}
func (q *TaskQueue) runOperationTask(task *Task) {
q.mu.Lock()
err := resolveTaskContainer(task)
q.mu.Unlock()
skipped := false
if err == nil && (task.Type == TaskStart || task.Type == TaskRestart || task.Type == TaskReinstall) {
c := config.FindContainer(task.ContainerID)
if c != nil {
if lxc.IsExpired(*c) {
err = fmt.Errorf("容器已到期,不允许此操作")
} else if lxc.IsTrafficExceeded(*c) {
err = fmt.Errorf("容器流量已超限,不允许此操作")
}
}
}
if err == nil && isSecurityStopTask(task) && !config.AppConfig.SecurityAutoShutdown {
skipped = true
}
if err == nil && !skipped {
switch task.Type {
case TaskStart:
err = startByRuntime(task.ContainerID)
case TaskStop:
err = stopByRuntime(task.ContainerID)
case TaskRestart:
err = restartByRuntime(task.ContainerID)
case TaskDelete:
err = destroyByRuntime(task.ContainerID)
if err == nil {
time.Sleep(time.Second)
if config.FindContainer(task.ContainerID) != nil {
err = fmt.Errorf("container still exists after delete: %d", task.ContainerID)
}
case TaskReinstall:
}
case TaskReinstall:
if lxc.HasSSHAuthOptions(task.Config) {
err = reinstallByRuntime(task.ContainerID, task.TemplateID, task.Config)
} else {
err = reinstallByRuntime(task.ContainerID, task.TemplateID)
}
}
q.mu.Lock()
auditUser := task.User
if auditUser == "" {
auditUser = "admin"
}
if err != nil {
task.Status = "failed"
task.Error = err.Error()
config.AddAuditLogFull(string(task.Type), task.ContainerName, "失败: "+err.Error(), auditUser, task.IP, task.UserAgent, false, err.Error())
} else {
task.Status = "done"
config.AddAuditLogFull(string(task.Type), task.ContainerName, "成功", auditUser, task.IP, task.UserAgent, true, "")
switch task.Type {
case TaskStart:
config.UpdateContainerStatus(task.ContainerID, "running")
clearPolicyBlockAfterAdminRecovery(task)
case TaskStop:
config.UpdateContainerStatus(task.ContainerID, "stopped")
case TaskRestart:
config.UpdateContainerStatus(task.ContainerID, "running")
clearPolicyBlockAfterAdminRecovery(task)
case TaskReinstall:
clearPolicyBlockAfterAdminRecovery(task)
}
}
q.persistTasks()
q.mu.Unlock()
}
auditUser := task.User
if auditUser == "" {
auditUser = "admin"
}
if err != nil {
config.AddAuditLogFull(string(task.Type), task.ContainerName, "失败: "+err.Error(), auditUser, task.IP, task.UserAgent, false, err.Error())
q.finishTask(task, "failed", err)
return
}
if skipped {
config.AddAuditLogFull(string(task.Type), task.ContainerName, "跳过: 安全告警自动关机已关闭", auditUser, task.IP, task.UserAgent, true, "")
q.finishTask(task, "done", nil)
return
}
config.AddAuditLogFull(string(task.Type), task.ContainerName, "成功", auditUser, task.IP, task.UserAgent, true, "")
switch task.Type {
case TaskStart:
config.UpdateContainerStatus(task.ContainerID, "running")
clearPolicyBlockAfterAdminRecovery(task)
case TaskStop:
config.UpdateContainerStatus(task.ContainerID, "stopped")
case TaskRestart:
config.UpdateContainerStatus(task.ContainerID, "running")
clearPolicyBlockAfterAdminRecovery(task)
case TaskReinstall:
clearPolicyBlockAfterAdminRecovery(task)
}
q.finishTask(task, "done", nil)
}
func isSecurityStopTask(task *Task) bool {
return task != nil && task.Type == TaskStop && task.User == "system:security"
}
func clearPolicyBlockAfterAdminRecovery(task *Task) {
@@ -444,7 +632,8 @@ func (q *TaskQueue) GetTasks() []*Task {
result := make([]*Task, 0, len(q.tasks))
// Collect all task IDs, sort by creation time (extracted from ID number)
for _, t := range q.tasks {
result = append(result, t)
copyTask := *t
result = append(result, &copyTask)
}
// Stable sort by ID number (task-N where N is sequential)
for i := 0; i < len(result); i++ {
@@ -472,6 +661,7 @@ func HandleSingleTaskAction(w http.ResponseWriter, r *http.Request, id int, acti
var taskType TaskType
var templateID string
var taskConfig *lxc.ContainerConfig
switch action {
case "start":
taskType = TaskStart
@@ -483,7 +673,10 @@ func HandleSingleTaskAction(w http.ResponseWriter, r *http.Request, id int, acti
taskType = TaskDelete
case "reinstall":
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)
templateID = req.TemplateID
@@ -497,17 +690,34 @@ func HandleSingleTaskAction(w http.ResponseWriter, r *http.Request, id int, acti
if c := config.FindContainer(id); c != nil {
runtime = c.Runtime()
}
if !isImageEnabledAndDownloaded(templateID, runtime) {
if !isTemplateAllowedForRequest(r, c, templateID) {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Template is not allowed for this user"})
return
}
if !isTemplateAvailableForRequest(r, c, templateID, runtime) {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Template is not enabled or downloaded"})
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
default:
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Unknown action"})
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{
Success: true,
Message: "Task queued",
@@ -542,6 +752,7 @@ func HandleBatchCreate(w http.ResponseWriter, r *http.Request) {
activeCreateNames := globalQueue.ActiveCreateNames()
requestNames := make(map[string]bool)
requestNATPorts := make(map[string]string)
for i := range req.Containers {
name := strings.TrimSpace(req.Containers[i].Name)
req.Containers[i].Name = name
@@ -564,19 +775,85 @@ func HandleBatchCreate(w http.ResponseWriter, r *http.Request) {
if req.Containers[i].VCPU <= 0 {
req.Containers[i].VCPU = 1
}
if err := rejectNegativeCreateLimits(req.Containers[i]); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: name + ": " + err.Error()})
return
}
req.Containers[i].NormalizeResourceAliases()
req.Containers[i].Virtualization = runtimeFromRequest(req.Containers[i].Virtualization)
if req.Containers[i].WantsLANIPv4() && req.Containers[i].Virtualization != config.VirtualizationLXC {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: name + ": LAN IPv4 is only supported for LXC containers"})
return
}
if req.Containers[i].RAMMB < 128 {
req.Containers[i].RAMMB = 512
}
if req.Containers[i].DiskGB < 1 {
req.Containers[i].DiskGB = 5
}
if err := validateCreateStoragePool(&req.Containers[i]); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: name + ": " + err.Error()})
return
}
if !isImageEnabledAndDownloaded(req.Containers[i].TemplateID, req.Containers[i].Virtualization) {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: name + ": template is not enabled or downloaded"})
return
}
if req.Containers[i].PortMappingCount < 2 {
req.Containers[i].PortMappingCount = 2
if ids, err := normalizeAllowedImageIDs(req.Containers[i].AllowedImageIDs); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: name + ": " + err.Error()})
return
} else {
req.Containers[i].AllowedImageIDs = ids
}
if req.Containers[i].PortMappingCount < 0 {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: name + ": port mapping count cannot be negative"})
return
}
if err := req.Containers[i].NormalizeCreateNATMappings(); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: name + ": " + err.Error()})
return
}
if err := lxc.ValidateCreateNATPortAvailability(req.Containers[i]); err != nil {
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: name + ": " + err.Error()})
return
}
if req.Containers[i].ManagementPort > 0 {
key := fmt.Sprintf("%d/tcp", req.Containers[i].ManagementPort)
if owner := requestNATPorts[key]; owner != "" {
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: fmt.Sprintf("%s: NAT management port %s is also requested by %s", name, key, owner)})
return
}
requestNATPorts[key] = name
}
for _, mapping := range req.Containers[i].NATPortMappings {
key := fmt.Sprintf("%d/%s", mapping.HostPort, mapping.Protocol)
if owner := requestNATPorts[key]; owner != "" {
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: fmt.Sprintf("%s: NAT host port %s is also requested by %s", name, key, owner)})
return
}
requestNATPorts[key] = name
}
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 {
req.Containers[i].SnapshotLimit = config.DefaultSnapshotLimit
@@ -585,9 +862,18 @@ func HandleBatchCreate(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: name + ": " + err.Error()})
return
}
if err := validateCreateSSHAuth(req.Containers[i]); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: name + ": " + err.Error()})
return
}
requestNames[name] = true
}
ids := globalQueue.EnqueueBatchCreateWithAudit(req.Containers, requestActor(r), clientIP(r), r.UserAgent())
planned, err := lxc.ReserveBatchCreateNATPorts(req.Containers)
if err != nil {
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: err.Error()})
return
}
ids := globalQueue.EnqueueBatchCreateWithAudit(planned, requestActor(r), clientIP(r), r.UserAgent())
jsonResponse(w, http.StatusAccepted, APIResponse{Success: true, Data: ids})
}
@@ -602,9 +888,12 @@ func HandleBatchAction(w http.ResponseWriter, r *http.Request) {
return
}
var req struct {
Action string `json:"action"`
Containers []int `json:"containers"`
TemplateID string `json:"template_id,omitempty"`
Action string `json:"action"`
Containers []int `json:"containers"`
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 {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
@@ -613,6 +902,7 @@ func HandleBatchAction(w http.ResponseWriter, r *http.Request) {
var taskType TaskType
var requiredScope string
var taskConfig *lxc.ContainerConfig
switch req.Action {
case "start":
taskType = TaskStart
@@ -635,6 +925,15 @@ func HandleBatchAction(w http.ResponseWriter, r *http.Request) {
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:
@@ -650,9 +949,32 @@ func HandleBatchAction(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Access denied to one or more containers"})
return
}
if taskType == TaskReinstall && !isTemplateAllowedForRequest(r, c, req.TemplateID) {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: c.Name + ": template is not allowed for this user"})
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.EnqueueBatchWithAudit(taskType, req.Containers, req.TemplateID, requestActor(r), clientIP(r), r.UserAgent())
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})
}
@@ -673,7 +995,8 @@ func HandleTaskDelete(w http.ResponseWriter, r *http.Request) {
return
}
globalQueue.mu.Lock()
if task := globalQueue.tasks[taskID]; task != nil && !isTaskAllowedForRequest(r, task) {
task := globalQueue.tasks[taskID]
if task != nil && !isTaskAllowedForRequest(r, task) {
globalQueue.mu.Unlock()
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Access denied to this task"})
return
@@ -696,6 +1019,9 @@ func HandleTaskDelete(w http.ResponseWriter, r *http.Request) {
globalQueue.opQueue = newOp
globalQueue.persistTasks()
globalQueue.mu.Unlock()
if task != nil && task.Type == TaskCreate {
lxc.ReleaseQueuedCreateNATPorts(task.Config.Name)
}
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Task deleted"})
}
@@ -715,7 +1041,12 @@ func HandleTasks(w http.ResponseWriter, r *http.Request) {
// RestoreTasks restores task queue from config
func RestoreTasks() {
globalQueue.mu.Lock()
defer globalQueue.mu.Unlock()
for _, st := range config.AppConfig.Tasks {
if st.Type == string(TaskStop) && st.User == "system:security" && !config.AppConfig.SecurityAutoShutdown {
continue
}
var cfg lxc.ContainerConfig
if st.Config != "" {
json.Unmarshal([]byte(st.Config), &cfg)
@@ -727,6 +1058,7 @@ func RestoreTasks() {
if cfg.Name == "" {
cfg.Name = containerName
}
cfg.NormalizeResourceAliases()
containerID := st.ContainerID
if containerID <= 0 && containerName != "" {
if c := config.FindContainerByName(containerName); c != nil {
@@ -740,6 +1072,8 @@ func RestoreTasks() {
ContainerName: containerName,
Status: st.Status,
Error: st.Error,
Stage: "queued",
StageDetail: "排队等待",
CreatedAt: st.CreatedAt,
TemplateID: st.TemplateID,
Config: cfg,
@@ -769,3 +1103,17 @@ func parseIDNum(id string) int {
}
return num
}
func validateCreateStoragePool(cfg *lxc.ContainerConfig) error {
required := config.StorageContentLXC
if cfg.Virtualization == config.VirtualizationKVM {
required = config.StorageContentKVM
}
requiredBytes := int64(cfg.DiskGB) * 1024 * 1024 * 1024
pool, err := config.SelectStoragePoolForContent(required, cfg.StoragePoolID, requiredBytes)
if err != nil {
return err
}
cfg.StoragePoolID = pool.ID
return nil
}
+56
View File
@@ -0,0 +1,56 @@
package api
import (
"testing"
"clicd/internal/config"
"clicd/internal/lxc"
)
func TestRunnableTaskIndexSkipsActiveContainer(t *testing.T) {
queue := []*Task{
{ID: "task-1", Type: TaskStop, ContainerID: 1, ContainerName: "alpha"},
{ID: "task-2", Type: TaskStart, ContainerID: 1, ContainerName: "alpha"},
{ID: "task-3", Type: TaskStart, ContainerID: 2, ContainerName: "beta"},
}
active := map[string]bool{taskConcurrencyKey(queue[0]): true}
if got := runnableTaskIndex(queue[1:], active); got != 1 {
t.Fatalf("runnableTaskIndex() = %d, want 1 for the other container", got)
}
}
func TestTaskConcurrencyKeyUsesContainerName(t *testing.T) {
create := &Task{ID: "task-1", Type: TaskCreate, Config: lxcConfigWithName("Example")}
operation := &Task{ID: "task-2", Type: TaskDelete, ContainerID: 9, ContainerName: "example"}
if taskConcurrencyKey(create) != taskConcurrencyKey(operation) {
t.Fatalf("same container received different concurrency keys: %q and %q", taskConcurrencyKey(create), taskConcurrencyKey(operation))
}
}
func TestTaskQueueSetConcurrencyNormalizesAndReports(t *testing.T) {
q := newTaskQueue(config.DefaultTaskConcurrency)
q.SetConcurrency(config.MaxTaskConcurrency + 10)
if got := q.Settings().Concurrency; got != config.MaxTaskConcurrency {
t.Fatalf("concurrency = %d, want %d", got, config.MaxTaskConcurrency)
}
q.SetConcurrency(0)
if got := q.Settings().Concurrency; got != config.DefaultTaskConcurrency {
t.Fatalf("concurrency = %d, want default %d", got, config.DefaultTaskConcurrency)
}
}
func TestTaskQueueUpdateTaskStage(t *testing.T) {
q := newTaskQueue(config.DefaultTaskConcurrency)
task := &Task{ID: "task-1", Type: TaskCreate, Status: "running"}
q.updateTaskStage(task, "rootfs", "下载模板并创建基础文件系统")
if task.Stage != "rootfs" || task.StageDetail != "下载模板并创建基础文件系统" {
t.Fatalf("unexpected task stage: %q %q", task.Stage, task.StageDetail)
}
}
func lxcConfigWithName(name string) lxc.ContainerConfig {
return lxc.ContainerConfig{Name: name}
}
+3 -17
View File
@@ -1,10 +1,9 @@
package api
import (
"net"
"net/http"
"net/url"
"strings"
"clicd/internal/config"
"github.com/gorilla/websocket"
)
@@ -17,19 +16,6 @@ var upgrader = websocket.Upgrader{
if origin == "" {
return true
}
originURL, err := url.Parse(origin)
if err != nil {
return false
}
originHost := strings.ToLower(stripPort(originURL.Host))
requestHost := strings.ToLower(stripPort(r.Host))
return originHost != "" && originHost == requestHost
return config.IsOriginAllowed(origin, r.Host)
},
}
func stripPort(host string) string {
if parsedHost, _, err := net.SplitHostPort(host); err == nil {
return parsedHost
}
return strings.Trim(host, "[]")
}
+86
View File
@@ -0,0 +1,86 @@
package cli
import (
"flag"
"fmt"
"strings"
"clicd/internal/config"
)
// RunAccessPolicyCommand manages the panel source policy without requiring the
// interactive menu. It is intended to remain usable over SSH as a recovery path.
func RunAccessPolicyCommand(args []string) error {
action := "show"
if len(args) > 0 {
action = strings.ToLower(strings.TrimSpace(args[0]))
args = args[1:]
}
switch action {
case "show":
printPanelAccessPolicy(config.AppConfig.PanelAccessPolicy)
return nil
case "disable", "off":
next := config.AppConfig.PanelAccessPolicy
next.Enabled = false
if err := savePanelAccessPolicy(next); err != nil {
return err
}
fmt.Println("Panel access allowlist disabled.")
return reloadPanelAfterAccessPolicyCommand()
case "set", "enable":
flags := flag.NewFlagSet("clicd access-policy set", flag.ContinueOnError)
flags.SetOutput(new(strings.Builder))
var allowed string
var trusted string
flags.StringVar(&allowed, "allow", "", "comma-separated allowed IP/CIDR values")
flags.StringVar(&trusted, "trusted-proxy", "", "comma-separated trusted proxy IP/CIDR values")
if err := flags.Parse(args); err != nil {
return fmt.Errorf("invalid access-policy arguments: %w", err)
}
next := config.PanelAccessPolicy{
Enabled: true,
AllowedSources: splitPanelAccessEntries(allowed),
TrustedProxies: splitPanelAccessEntries(trusted),
}
if err := savePanelAccessPolicy(next); err != nil {
return err
}
fmt.Println("Panel access allowlist saved.")
printPanelAccessPolicy(config.AppConfig.PanelAccessPolicy)
return reloadPanelAfterAccessPolicyCommand()
default:
return fmt.Errorf("unknown access-policy action %q; use show, set, or disable", action)
}
}
func savePanelAccessPolicy(policy config.PanelAccessPolicy) error {
normalized, err := config.NormalizePanelAccessPolicy(policy)
if err != nil {
return err
}
previous := config.AppConfig.PanelAccessPolicy
config.AppConfig.PanelAccessPolicy = normalized
if err := config.SaveConfig(); err != nil {
config.AppConfig.PanelAccessPolicy = previous
return fmt.Errorf("save panel access policy: %w", err)
}
return nil
}
func reloadPanelAfterAccessPolicyCommand() error {
if !isWebPanelRunning() {
return nil
}
if err := restartService("clicd"); err != nil {
return fmt.Errorf("policy was saved but clicd service restart failed: %w", err)
}
return nil
}
func printPanelAccessPolicy(policy config.PanelAccessPolicy) {
fmt.Printf("Enabled: %t\n", policy.Enabled)
fmt.Printf("Allowed sources: %s\n", strings.Join(policy.AllowedSources, ", "))
fmt.Printf("Trusted proxies: %s\n", strings.Join(policy.TrustedProxies, ", "))
}
+152 -45
View File
@@ -9,6 +9,7 @@ import (
"os"
"os/exec"
"path/filepath"
"runtime"
"sort"
"strconv"
"strings"
@@ -34,7 +35,7 @@ var cliTranslations = map[string]string{
"请选择操作": "Select an action",
"再见": "Goodbye",
"无效选择": "Invalid choice",
"CLICD - LXC 容器管理器": "CLICD - LXC Container Manager",
"CLICD - LXC 容器管理器": "CLICD - Container Manager",
"Web 面板": "Web panel",
"端口": "port",
"运行中": "running",
@@ -53,6 +54,7 @@ var cliTranslations = map[string]string{
"导入现有 LXC 容器": "Import existing LXC containers",
"检查并升级 CLICD": "Check and upgrade CLICD",
"卸载 CLICD": "Uninstall CLICD",
"面板访问白名单": "Panel access allowlist",
"系统信息": "System info",
"退出": "Exit",
"获取容器列表失败": "Failed to get container list",
@@ -125,32 +127,34 @@ var cliTranslations = map[string]string{
"检查仓库": "Checking repository",
"检查 GitHub 最新版本失败": "Failed to check the latest GitHub version",
"GitHub Release 没有 tag_name,无法判断最新版本。": "GitHub Release has no tag_name, so the latest version cannot be determined.",
"最新版本": "Latest version",
"发布页面": "Release page",
"最新 Release 没有找到 clicd-linux-amd64.tar.gz,无法自动升级": "The latest release does not contain clicd-linux-amd64.tar.gz, so automatic upgrade is unavailable.",
"当前已经是最新版本。": "The current version is already the latest.",
"是否仍然重新安装最新版本?输入 reinstall 继续": "Reinstall the latest version anyway? Type reinstall to continue",
"输入 upgrade 开始升级": "Type upgrade to start upgrade",
"已取消。": "Cancelled.",
"升级失败": "Upgrade failed",
"升级完成": "Upgrade completed",
"原有数据已保留,Web 服务已重启。": "Existing data has been kept and the web service has been restarted.",
"GitHub API 返回": "GitHub API returned",
"GitHub API 被限流,已切换到备用检查方式。": "GitHub API rate limit reached; switched to fallback check.",
"GitHub API 不可用,已切换到备用检查方式。": "GitHub API is unavailable; switched to fallback check.",
"GitHub releases/latest 返回": "GitHub releases/latest returned",
"无法从 GitHub releases/latest 跳转结果解析最新版本": "Unable to parse the latest version from the GitHub releases/latest redirect",
"正在下载升级包...": "Downloading upgrade package...",
"正在解压升级包...": "Extracting upgrade package...",
"解压失败": "Extraction failed",
"备份旧二进制失败": "Failed to back up old binary",
"旧版本已备份": "Old version backed up",
"正在替换二进制...": "Replacing binary...",
"停止 Web 服务失败,继续尝试替换": "Failed to stop web service; continuing replacement attempt",
"二进制已替换,但重启 Web 服务失败": "Binary was replaced, but restarting the web service failed",
"下载失败,HTTP": "Download failed, HTTP",
"升级包内未找到 clicd 二进制": "No clicd binary found in the upgrade package",
"将 /var/lib/lxc 里的容器导入 CLICD 配置。": "Import containers under /var/lib/lxc into CLICD configuration.",
"最新版本": "Latest version",
"发布页面": "Release page",
"当前架构不支持自动升级": "Automatic upgrade is not supported on the current architecture",
"最新 Release 没有找到": "The latest release does not contain",
"无法自动升级。": "automatic upgrade is unavailable.",
"当前已经是最新版本。": "The current version is already the latest.",
"是否仍然重新安装最新版本?输入 reinstall 继续": "Reinstall the latest version anyway? Type reinstall to continue",
"输入 upgrade 开始升级": "Type upgrade to start upgrade",
"已取消。": "Cancelled.",
"升级失败": "Upgrade failed",
"升级完成": "Upgrade completed",
"原有数据已保留,Web 服务已重启。": "Existing data has been kept and the web service has been restarted.",
"GitHub API 返回": "GitHub API returned",
"GitHub API 被限流,已切换到备用检查方式。": "GitHub API rate limit reached; switched to fallback check.",
"GitHub API 不可用,已切换到备用检查方式。": "GitHub API is unavailable; switched to fallback check.",
"GitHub releases/latest 返回": "GitHub releases/latest returned",
"无法从 GitHub releases/latest 跳转结果解析最新版本": "Unable to parse the latest version from the GitHub releases/latest redirect",
"正在下载升级包...": "Downloading upgrade package...",
"正在解压升级包...": "Extracting upgrade package...",
"解压失败": "Extraction failed",
"备份旧二进制失败": "Failed to back up old binary",
"旧版本已备份": "Old version backed up",
"正在替换二进制...": "Replacing binary...",
"停止 Web 服务失败,继续尝试替换": "Failed to stop web service; continuing replacement attempt",
"二进制已替换,但重启 Web 服务失败": "Binary was replaced, but restarting the web service failed",
"下载失败,HTTP": "Download failed, HTTP",
"升级包内未找到 clicd 二进制": "No clicd binary found in the upgrade package",
"将 /var/lib/lxc 里的容器导入 CLICD 配置。": "Import containers under /var/lib/lxc into CLICD configuration.",
"导入后会保留真实 LXC 名称,Web 和 CLI 都能管理同一个容器。": "After import, real LXC names are kept and both Web and CLI can manage the same containers.",
"导入失败": "Import failed",
"没有发现新的 ct-* 容器。": "No new ct-* containers found.",
@@ -172,10 +176,26 @@ var cliTranslations = map[string]string{
"LXC 版本": "LXC version",
"暂无可用容器": "No available containers",
"忽略无效端口": "Ignoring invalid port",
"": "? ",
"": ". ",
"": ", ",
"": ": ",
"面板访问来源策略": "Panel access source policy",
"当前状态": "Current status",
"已启用": "enabled",
"已关闭": "disabled",
"允许来源": "Allowed sources",
"可信代理": "Trusted proxies",
"启用或修改白名单": "Enable or update allowlist",
"关闭白名单限制": "Disable allowlist",
"取消": "Cancel",
"允许的 IP/CIDR,多个用逗号分隔": "Allowed IP/CIDR values, comma-separated",
"可信代理 IP/CIDR,多个用逗号分隔,可留空": "Trusted proxy IP/CIDR values, comma-separated; optional",
"白名单配置无效": "Invalid allowlist configuration",
"保存访问来源策略失败": "Failed to save access source policy",
"面板访问白名单已保存。": "Panel access allowlist saved.",
"面板访问白名单已关闭。": "Panel access allowlist disabled.",
"至少填写一个允许的 IP 或网段。": "Enter at least one allowed IP address or network.",
"": "? ",
"。": ". ",
"": ", ",
"": ": ",
}
// Run starts the CLI interface.
@@ -190,7 +210,7 @@ func Run() {
refreshCLILanguage()
clearScreen()
printMenu()
cliPrint("\n请选择操作 [1-12,l,0/q]: ")
cliPrint("\n请选择操作 [1-13,l,0/q]: ")
input, _ := reader.ReadString('\n')
input = strings.TrimSpace(input)
@@ -243,6 +263,10 @@ func Run() {
clearScreen()
cliUninstall(reader)
return
case "13":
clearScreen()
cliConfigurePanelAccess(reader)
waitEnter(reader)
case "0":
clearScreen()
cliShowInfo()
@@ -290,11 +314,80 @@ func printMenu() {
cliPrintln(" 10. 导入现有 LXC 容器")
cliPrintln(" 11. 检查并升级 CLICD")
cliPrintln(" 12. 卸载 CLICD")
cliPrintln(" 13. 面板访问白名单")
cliPrintln(" 0. 系统信息")
cliPrintln(" l. 切换语言")
cliPrintln(" q. 退出")
}
func cliConfigurePanelAccess(reader *bufio.Reader) {
cliPrintf("\n--- %s ---\n", cliT("面板访问来源策略"))
policy := config.AppConfig.PanelAccessPolicy
status := cliT("已关闭")
if policy.Enabled {
status = cliT("已启用")
}
cliPrintf("%s: %s\n", cliT("当前状态"), status)
cliPrintf("%s: %s\n", cliT("允许来源"), strings.Join(policy.AllowedSources, ", "))
cliPrintf("%s: %s\n", cliT("可信代理"), strings.Join(policy.TrustedProxies, ", "))
cliPrintf("\n 1. %s\n", cliT("启用或修改白名单"))
cliPrintf(" 2. %s\n", cliT("关闭白名单限制"))
cliPrintf(" 0. %s\n", cliT("取消"))
choice := promptString(reader, "请选择操作", "0")
next := policy
switch strings.TrimSpace(choice) {
case "1":
allowed := promptString(reader, "允许的 IP/CIDR,多个用逗号分隔", strings.Join(policy.AllowedSources, ","))
allowedSources := splitPanelAccessEntries(allowed)
if len(allowedSources) == 0 {
cliPrintln("至少填写一个允许的 IP 或网段。")
return
}
trusted := promptString(reader, "可信代理 IP/CIDR,多个用逗号分隔,可留空", strings.Join(policy.TrustedProxies, ","))
next = config.PanelAccessPolicy{
Enabled: true,
AllowedSources: allowedSources,
TrustedProxies: splitPanelAccessEntries(trusted),
}
case "2":
next.Enabled = false
case "0", "":
cliPrintln("已取消")
return
default:
cliPrintln("无效选择")
return
}
normalized, err := config.NormalizePanelAccessPolicy(next)
if err != nil {
cliPrintf("%s: %v\n", cliT("白名单配置无效"), err)
return
}
previous := config.AppConfig.PanelAccessPolicy
config.AppConfig.PanelAccessPolicy = normalized
if err := config.SaveConfig(); err != nil {
config.AppConfig.PanelAccessPolicy = previous
cliPrintf("%s: %v\n", cliT("保存访问来源策略失败"), err)
return
}
if normalized.Enabled {
cliPrintln("面板访问白名单已保存。")
} else {
cliPrintln("面板访问白名单已关闭。")
}
if isWebPanelRunning() {
restartWebPanelForConfigChange()
}
}
func splitPanelAccessEntries(value string) []string {
return strings.FieldsFunc(value, func(r rune) bool {
return r == ',' || r == ';' || r == '\n' || r == '\r' || r == '\t' || r == ' '
})
}
func cliSwitchLanguage(reader *bufio.Reader) {
cliPrintf("\n--- %s ---\n", cliT("切换语言"))
cliPrintf("%s: %s\n", cliT("当前语言"), cliLanguageLabel(config.NormalizeLanguage(config.AppConfig.Language)))
@@ -388,6 +481,7 @@ func cliCreateContainer(reader *bufio.Reader) {
IOSpeedMBps: promptInt(reader, "IO 速度 (MB/s)", 500),
ExtraPorts: promptPortList(reader, "额外 NAT 端口,多个用逗号分隔"),
}
cfg.NormalizeResourceAliases()
cliPrintf("\n正在创建容器 %s ...\n", name)
if err := manager.CreateContainer(cfg); err != nil {
@@ -556,11 +650,16 @@ func cliUpgradeSystem(reader *bufio.Reader) {
if repo == "" {
repo = version.Repo
}
assetName, err := releaseArchiveAssetName(runtime.GOARCH)
if err != nil {
cliPrintf("当前架构不支持自动升级: %s\n", runtime.GOARCH)
return
}
current := version.Current()
cliPrintf("当前版本: %s\n", current)
cliPrintf("检查仓库: https://github.com/%s\n", repo)
release, err := fetchLatestRelease(repo)
release, err := fetchLatestRelease(repo, assetName)
if err != nil {
cliPrintf("检查 GitHub 最新版本失败: %v\n", err)
return
@@ -575,9 +674,9 @@ func cliUpgradeSystem(reader *bufio.Reader) {
cliPrintf("发布页面: %s\n", release.HTMLURL)
}
assetURL := findReleaseAsset(release, "clicd-linux-amd64.tar.gz")
assetURL := findReleaseAsset(release, assetName)
if assetURL == "" {
cliPrintln("最新 Release 没有找到 clicd-linux-amd64.tar.gz,无法自动升级。")
cliPrintf("最新 Release 没有找到 %s,无法自动升级。\n", assetName)
return
}
@@ -596,7 +695,7 @@ func cliUpgradeSystem(reader *bufio.Reader) {
}
}
if err := upgradeFromReleaseAsset(assetURL, latest); err != nil {
if err := upgradeFromReleaseAsset(assetURL, latest, assetName); err != nil {
cliPrintf("升级失败: %v\n", err)
return
}
@@ -604,7 +703,7 @@ func cliUpgradeSystem(reader *bufio.Reader) {
cliPrintln("原有数据已保留,Web 服务已重启。")
}
func fetchLatestRelease(repo string) (*githubRelease, error) {
func fetchLatestRelease(repo, assetName string) (*githubRelease, error) {
url := fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", repo)
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
@@ -616,7 +715,7 @@ func fetchLatestRelease(repo string) (*githubRelease, error) {
client := &http.Client{Timeout: 20 * time.Second}
resp, err := client.Do(req)
if err != nil {
if fallback, fallbackErr := fetchLatestReleaseFallback(repo); fallbackErr == nil {
if fallback, fallbackErr := fetchLatestReleaseFallback(repo, assetName); fallbackErr == nil {
return fallback, nil
}
return nil, err
@@ -626,7 +725,7 @@ func fetchLatestRelease(repo string) (*githubRelease, error) {
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
apiErr := fmt.Errorf("GitHub API 返回 %s: %s", resp.Status, strings.TrimSpace(string(body)))
if fallback, fallbackErr := fetchLatestReleaseFallback(repo); fallbackErr == nil {
if fallback, fallbackErr := fetchLatestReleaseFallback(repo, assetName); fallbackErr == nil {
if resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusTooManyRequests {
cliPrintln("GitHub API 被限流,已切换到备用检查方式。")
} else {
@@ -644,7 +743,7 @@ func fetchLatestRelease(repo string) (*githubRelease, error) {
return &release, nil
}
func fetchLatestReleaseFallback(repo string) (*githubRelease, error) {
func fetchLatestReleaseFallback(repo, assetName string) (*githubRelease, error) {
req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("https://github.com/%s/releases/latest", repo), nil)
if err != nil {
return nil, err
@@ -666,7 +765,6 @@ func fetchLatestReleaseFallback(repo string) (*githubRelease, error) {
return nil, fmt.Errorf("无法从 GitHub releases/latest 跳转结果解析最新版本")
}
const assetName = "clicd-linux-amd64.tar.gz"
return &githubRelease{
TagName: tag,
Name: tag,
@@ -707,6 +805,15 @@ func setGitHubRequestHeaders(req *http.Request) {
}
}
func releaseArchiveAssetName(goarch string) (string, error) {
switch goarch {
case "amd64", "arm64":
return fmt.Sprintf("clicd-linux-%s.tar.gz", goarch), nil
default:
return "", fmt.Errorf("unsupported architecture: %s", goarch)
}
}
func findReleaseAsset(release *githubRelease, name string) string {
for _, asset := range release.Assets {
if asset.Name == name && asset.BrowserDownloadURL != "" {
@@ -716,14 +823,14 @@ func findReleaseAsset(release *githubRelease, name string) string {
return ""
}
func upgradeFromReleaseAsset(assetURL, latest string) error {
func upgradeFromReleaseAsset(assetURL, latest, assetName string) error {
tmpDir, err := os.MkdirTemp("", "clicd-upgrade-*")
if err != nil {
return err
}
defer os.RemoveAll(tmpDir)
archivePath := filepath.Join(tmpDir, "clicd-linux-amd64.tar.gz")
archivePath := filepath.Join(tmpDir, assetName)
cliPrintln("正在下载升级包...")
if err := downloadFile(assetURL, archivePath); err != nil {
return err
@@ -1227,8 +1334,8 @@ func removeCLICDNATRules() {
break
}
}
deleteNATRule("POSTROUTING", "-s", "10.0.3.0/24", "-o", "eth+", "-j", "MASQUERADE")
deleteNATRule("POSTROUTING", "-s", "192.168.122.0/24", "-o", "eth+", "-j", "MASQUERADE")
deleteNATRule("POSTROUTING", "-s", config.LXCNATNetwork().Subnet, "-o", "eth+", "-j", "MASQUERADE")
deleteNATRule("POSTROUTING", "-s", config.KVMNATNetwork().Subnet, "-o", "eth+", "-j", "MASQUERADE")
}
}
+20
View File
@@ -19,6 +19,26 @@ func TestSafeReleaseBackupComponent(t *testing.T) {
}
}
func TestReleaseArchiveAssetName(t *testing.T) {
tests := map[string]string{
"amd64": "clicd-linux-amd64.tar.gz",
"arm64": "clicd-linux-arm64.tar.gz",
}
for goarch, want := range tests {
got, err := releaseArchiveAssetName(goarch)
if err != nil {
t.Fatalf("releaseArchiveAssetName(%q) error = %v", goarch, err)
}
if got != want {
t.Fatalf("releaseArchiveAssetName(%q) = %q, want %q", goarch, got, want)
}
}
if _, err := releaseArchiveAssetName("386"); err == nil {
t.Fatal("releaseArchiveAssetName(386) error = nil, want unsupported architecture")
}
}
func TestCopyFileToBackupRejectsUnsafeFileName(t *testing.T) {
unsafeNames := []string{
"../clicd",
File diff suppressed because it is too large Load Diff
+144
View File
@@ -0,0 +1,144 @@
package config
import (
"encoding/binary"
"fmt"
"net/netip"
"os"
"strings"
)
const (
DefaultLXCNATSubnet = "10.0.3.0/24"
DefaultKVMNATSubnet = "192.168.122.0/24"
)
type NATNetwork struct {
Subnet string `json:"subnet"`
Gateway string `json:"gateway"`
Netmask string `json:"netmask"`
DHCPStart string `json:"dhcp_start"`
DHCPEnd string `json:"dhcp_end"`
DHCPMax int `json:"dhcp_max"`
PrefixBits int `json:"prefix_bits"`
}
func ParseNATNetwork(raw string) (NATNetwork, error) {
prefix, err := netip.ParsePrefix(strings.TrimSpace(raw))
if err != nil || !prefix.Addr().Is4() {
return NATNetwork{}, fmt.Errorf("NAT subnet must be a valid IPv4 CIDR")
}
prefix = prefix.Masked()
if prefix.Bits() < 16 || prefix.Bits() > 28 {
return NATNetwork{}, fmt.Errorf("NAT subnet prefix must be between /16 and /28")
}
if !isRFC1918Prefix(prefix) {
return NATNetwork{}, fmt.Errorf("NAT subnet must use an RFC1918 private IPv4 range")
}
network := ipv4Uint32(prefix.Addr())
hostBits := 32 - prefix.Bits()
broadcast := network | uint32((uint64(1)<<hostBits)-1)
gateway := uint32IPv4(network + 1)
dhcpStart := uint32IPv4(network + 2)
dhcpEnd := uint32IPv4(broadcast - 1)
return NATNetwork{
Subnet: prefix.String(),
Gateway: gateway.String(),
Netmask: netmaskString(prefix.Bits()),
DHCPStart: dhcpStart.String(),
DHCPEnd: dhcpEnd.String(),
DHCPMax: int(broadcast - network - 2),
PrefixBits: prefix.Bits(),
}, nil
}
func LXCNATNetwork() NATNetwork {
return configuredNATNetwork(false)
}
func KVMNATNetwork() NATNetwork {
return configuredNATNetwork(true)
}
func normalizeNATNetworkDefaults() bool {
changed := false
lxcSubnet := configuredSubnetValue(AppConfig.LXCNATSubnet, "CLICD_LXC_SUBNET", DefaultLXCNATSubnet)
kvmSubnet := configuredSubnetValue(AppConfig.KVMNATSubnet, "CLICD_KVM_SUBNET", DefaultKVMNATSubnet)
if AppConfig.LXCNATSubnet != lxcSubnet {
AppConfig.LXCNATSubnet = lxcSubnet
changed = true
}
if AppConfig.KVMNATSubnet != kvmSubnet {
AppConfig.KVMNATSubnet = kvmSubnet
changed = true
}
return changed
}
func configuredNATNetwork(kvm bool) NATNetwork {
raw := DefaultLXCNATSubnet
if kvm {
raw = DefaultKVMNATSubnet
}
if AppConfig != nil {
if kvm && AppConfig.KVMNATSubnet != "" {
raw = AppConfig.KVMNATSubnet
}
if !kvm && AppConfig.LXCNATSubnet != "" {
raw = AppConfig.LXCNATSubnet
}
}
network, err := ParseNATNetwork(raw)
if err == nil {
return network
}
network, _ = ParseNATNetwork(map[bool]string{false: DefaultLXCNATSubnet, true: DefaultKVMNATSubnet}[kvm])
return network
}
func configuredSubnetValue(current, envName, fallback string) string {
raw := strings.TrimSpace(current)
if envValue := strings.TrimSpace(os.Getenv(envName)); envValue != "" {
raw = envValue
}
if network, err := ParseNATNetwork(raw); err == nil {
return network.Subnet
}
network, _ := ParseNATNetwork(fallback)
return network.Subnet
}
func isRFC1918Prefix(prefix netip.Prefix) bool {
privateRanges := []netip.Prefix{
netip.MustParsePrefix("10.0.0.0/8"),
netip.MustParsePrefix("172.16.0.0/12"),
netip.MustParsePrefix("192.168.0.0/16"),
}
for _, privateRange := range privateRanges {
if privateRange.Contains(prefix.Addr()) {
last := uint32IPv4(ipv4Uint32(prefix.Addr()) | uint32((uint64(1)<<(32-prefix.Bits()))-1))
return privateRange.Contains(last)
}
}
return false
}
func ipv4Uint32(addr netip.Addr) uint32 {
bytes := addr.As4()
return binary.BigEndian.Uint32(bytes[:])
}
func uint32IPv4(value uint32) netip.Addr {
var bytes [4]byte
binary.BigEndian.PutUint32(bytes[:], value)
return netip.AddrFrom4(bytes)
}
func netmaskString(bits int) string {
mask := uint32(0)
if bits > 0 {
mask = ^uint32(0) << (32 - bits)
}
return uint32IPv4(mask).String()
}
@@ -0,0 +1,56 @@
package config
import "testing"
func TestParseNATNetwork(t *testing.T) {
network, err := ParseNATNetwork("172.28.40.0/24")
if err != nil {
t.Fatalf("ParseNATNetwork returned error: %v", err)
}
if network.Subnet != "172.28.40.0/24" ||
network.Gateway != "172.28.40.1" ||
network.Netmask != "255.255.255.0" ||
network.DHCPStart != "172.28.40.2" ||
network.DHCPEnd != "172.28.40.254" ||
network.DHCPMax != 253 {
t.Fatalf("unexpected network values: %+v", network)
}
}
func TestParseNATNetworkMasksHostBits(t *testing.T) {
network, err := ParseNATNetwork("10.44.8.99/20")
if err != nil {
t.Fatalf("ParseNATNetwork returned error: %v", err)
}
if network.Subnet != "10.44.0.0/20" || network.Gateway != "10.44.0.1" || network.DHCPEnd != "10.44.15.254" {
t.Fatalf("unexpected masked network values: %+v", network)
}
}
func TestParseNATNetworkRejectsUnsafeRanges(t *testing.T) {
for _, raw := range []string{
"203.0.113.0/24",
"10.0.0.0/15",
"10.0.0.0/29",
"not-a-subnet",
} {
if _, err := ParseNATNetwork(raw); err == nil {
t.Fatalf("ParseNATNetwork(%q) unexpectedly succeeded", raw)
}
}
}
func TestNormalizeNATNetworkDefaultsUsesEnvironment(t *testing.T) {
t.Setenv("CLICD_LXC_SUBNET", "172.30.8.0/24")
t.Setenv("CLICD_KVM_SUBNET", "10.230.0.0/20")
previous := AppConfig
AppConfig = &ClicdConfig{}
t.Cleanup(func() { AppConfig = previous })
if !normalizeNATNetworkDefaults() {
t.Fatal("expected defaults to change")
}
if AppConfig.LXCNATSubnet != "172.30.8.0/24" || AppConfig.KVMNATSubnet != "10.230.0.0/20" {
t.Fatalf("unexpected configured subnets: LXC=%s KVM=%s", AppConfig.LXCNATSubnet, AppConfig.KVMNATSubnet)
}
}
+88
View File
@@ -0,0 +1,88 @@
package config
import "testing"
func TestAllocateSSHPortUsesConfiguredNATRange(t *testing.T) {
AppConfig = &ClicdConfig{
NATPortStart: 30000,
NATPortEnd: 30002,
NextSSHPort: 22000,
Containers: []Container{{
PortMappings: []PortMapping{
{HostPort: 30000},
{HostPort: 30001},
},
}},
}
port, err := AllocateSSHPort()
if err != nil {
t.Fatal(err)
}
if port != 30002 {
t.Fatalf("expected port 30002, got %d", port)
}
if AppConfig.NextSSHPort != 30000 {
t.Fatalf("expected next port to wrap to 30000, got %d", AppConfig.NextSSHPort)
}
}
func TestAllocateSSHPortErrorsWhenConfiguredRangeIsFull(t *testing.T) {
AppConfig = &ClicdConfig{
NATPortStart: 31000,
NATPortEnd: 31001,
NextSSHPort: 31000,
Containers: []Container{{
PortMappings: []PortMapping{
{HostPort: 31000},
{HostPort: 31001},
},
}},
}
if port, err := AllocateSSHPort(); err == nil {
t.Fatalf("expected exhausted NAT range error, got port %d", port)
}
}
func TestAllocateSSHPortExcludingRequestedMappings(t *testing.T) {
previous := AppConfig
t.Cleanup(func() { AppConfig = previous })
AppConfig = &ClicdConfig{
NATPortStart: 32000,
NATPortEnd: 32002,
NextSSHPort: 32000,
}
port, err := AllocateSSHPortExcluding([]int{32000, 32001})
if err != nil {
t.Fatal(err)
}
if port != 32002 {
t.Fatalf("allocated port = %d, want 32002", port)
}
}
func TestPreviewSSHPortUsesRangeWithoutAdvancingCursor(t *testing.T) {
previous := AppConfig
t.Cleanup(func() { AppConfig = previous })
AppConfig = &ClicdConfig{
NATPortStart: 30000,
NATPortEnd: 35000,
NextSSHPort: 30000,
Containers: []Container{{
PortMappings: []PortMapping{{HostPort: 30000}},
}},
}
port, err := PreviewSSHPortExcluding([]int{30001})
if err != nil {
t.Fatal(err)
}
if port != 30002 {
t.Fatalf("preview port = %d, want 30002", port)
}
if AppConfig.NextSSHPort != 30000 {
t.Fatalf("preview advanced cursor to %d", AppConfig.NextSSHPort)
}
}
+136
View File
@@ -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()
}
+198
View File
@@ -0,0 +1,198 @@
package config
import (
"fmt"
"net"
"net/netip"
"strings"
)
// PanelAccessPolicy limits access to the complete web panel and API surface.
type PanelAccessPolicy struct {
Enabled bool `json:"enabled"`
AllowedSources []string `json:"allowed_sources"`
TrustedProxies []string `json:"trusted_proxies"`
}
// ForwardedClientHeaders contains proxy-provided client address headers.
type ForwardedClientHeaders struct {
ForwardedFor string
RealIP string
CFConnectingIP string
}
// PanelAccessDecision describes the address used by the access policy.
type PanelAccessDecision struct {
Allowed bool
DirectSource string
CurrentSource string
UsedForwarded bool
}
func NormalizePanelAccessPolicy(policy PanelAccessPolicy) (PanelAccessPolicy, error) {
allowed, err := normalizeIPRanges(policy.AllowedSources, "allowed source")
if err != nil {
return PanelAccessPolicy{}, err
}
trusted, err := normalizeIPRanges(policy.TrustedProxies, "trusted proxy")
if err != nil {
return PanelAccessPolicy{}, err
}
if policy.Enabled && len(allowed) == 0 {
return PanelAccessPolicy{}, fmt.Errorf("at least one allowed IP address or CIDR is required")
}
return PanelAccessPolicy{
Enabled: policy.Enabled,
AllowedSources: allowed,
TrustedProxies: trusted,
}, nil
}
func normalizeIPRanges(values []string, label string) ([]string, error) {
result := make([]string, 0, len(values))
seen := make(map[string]struct{}, len(values))
for _, raw := range values {
value := strings.TrimSpace(raw)
if value == "" {
continue
}
normalized, err := normalizeIPRange(value)
if err != nil {
return nil, fmt.Errorf("invalid %s %q: %w", label, value, err)
}
if _, exists := seen[normalized]; exists {
continue
}
seen[normalized] = struct{}{}
result = append(result, normalized)
}
return result, nil
}
func normalizeIPRange(value string) (string, error) {
if strings.Contains(value, "/") {
prefix, err := netip.ParsePrefix(value)
if err != nil {
return "", err
}
if prefix.Addr().Zone() != "" {
return "", fmt.Errorf("IPv6 zones are not supported")
}
return prefix.Masked().String(), nil
}
addr, err := netip.ParseAddr(value)
if err != nil {
return "", err
}
if addr.Zone() != "" {
return "", fmt.Errorf("IPv6 zones are not supported")
}
return addr.Unmap().String(), nil
}
func panelAccessPoliciesEqual(a, b PanelAccessPolicy) bool {
return a.Enabled == b.Enabled &&
stringSlicesEqual(a.AllowedSources, b.AllowedSources) &&
stringSlicesEqual(a.TrustedProxies, b.TrustedProxies)
}
func stringSlicesEqual(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
// EvaluatePanelAccess resolves the effective client address and applies policy.
// Forwarded headers are only considered when the TCP peer is trusted.
func EvaluatePanelAccess(policy PanelAccessPolicy, remoteAddr string, headers ForwardedClientHeaders) PanelAccessDecision {
direct, ok := parseRemoteIP(remoteAddr)
decision := PanelAccessDecision{}
if ok {
decision.DirectSource = direct.String()
decision.CurrentSource = direct.String()
}
if !policy.Enabled {
decision.Allowed = true
return decision
}
if !ok {
return decision
}
current := direct
if ipInRanges(direct, policy.TrustedProxies) {
if forwarded, forwardedOK := resolveForwardedIP(direct, policy.TrustedProxies, headers); forwardedOK {
current = forwarded
decision.CurrentSource = forwarded.String()
decision.UsedForwarded = true
}
}
// A direct local connection remains an emergency recovery path. When a
// trusted local reverse proxy forwards a client address, that client is
// still checked normally.
if current.IsLoopback() && !decision.UsedForwarded {
decision.Allowed = true
return decision
}
decision.Allowed = ipInRanges(current, policy.AllowedSources)
return decision
}
func parseRemoteIP(value string) (netip.Addr, bool) {
value = strings.TrimSpace(value)
if host, _, err := net.SplitHostPort(value); err == nil {
value = host
}
value = strings.TrimPrefix(strings.TrimSuffix(value, "]"), "[")
addr, err := netip.ParseAddr(value)
if err != nil {
return netip.Addr{}, false
}
return addr.Unmap(), true
}
func resolveForwardedIP(direct netip.Addr, trusted []string, headers ForwardedClientHeaders) (netip.Addr, bool) {
for _, raw := range []string{headers.CFConnectingIP, headers.RealIP} {
if addr, ok := parseRemoteIP(strings.TrimSpace(strings.Split(raw, ",")[0])); ok {
return addr, true
}
}
parts := strings.Split(headers.ForwardedFor, ",")
current := direct
found := false
for i := len(parts) - 1; i >= 0 && ipInRanges(current, trusted); i-- {
addr, ok := parseRemoteIP(strings.TrimSpace(parts[i]))
if !ok {
continue
}
current = addr
found = true
}
return current, found
}
func ipInRanges(addr netip.Addr, ranges []string) bool {
addr = addr.Unmap()
for _, raw := range ranges {
if strings.Contains(raw, "/") {
prefix, err := netip.ParsePrefix(raw)
if err == nil && prefix.Contains(addr) {
return true
}
continue
}
candidate, err := netip.ParseAddr(raw)
if err == nil && candidate.Unmap() == addr {
return true
}
}
return false
}
@@ -0,0 +1,146 @@
package config
import (
"reflect"
"testing"
)
func TestNormalizePanelAccessPolicy(t *testing.T) {
policy, err := NormalizePanelAccessPolicy(PanelAccessPolicy{
Enabled: true,
AllowedSources: []string{" 192.0.2.8 ", "10.20.30.44/24", "192.0.2.8", "2001:db8::1"},
TrustedProxies: []string{"127.0.0.1", "2001:db8:1::/64"},
})
if err != nil {
t.Fatalf("NormalizePanelAccessPolicy() error = %v", err)
}
if want := []string{"192.0.2.8", "10.20.30.0/24", "2001:db8::1"}; !reflect.DeepEqual(policy.AllowedSources, want) {
t.Fatalf("AllowedSources = %#v, want %#v", policy.AllowedSources, want)
}
if want := []string{"127.0.0.1", "2001:db8:1::/64"}; !reflect.DeepEqual(policy.TrustedProxies, want) {
t.Fatalf("TrustedProxies = %#v, want %#v", policy.TrustedProxies, want)
}
}
func TestNormalizePanelAccessPolicyRejectsEmptyEnabledPolicy(t *testing.T) {
if _, err := NormalizePanelAccessPolicy(PanelAccessPolicy{Enabled: true}); err == nil {
t.Fatal("expected enabled empty policy to fail")
}
}
func TestEvaluatePanelAccess(t *testing.T) {
base := PanelAccessPolicy{
Enabled: true,
AllowedSources: []string{"192.0.2.0/24", "2001:db8::/32"},
TrustedProxies: []string{"10.0.0.1", "127.0.0.1"},
}
tests := []struct {
name string
policy PanelAccessPolicy
remote string
headers ForwardedClientHeaders
allowed bool
current string
usedForwarded bool
}{
{
name: "disabled",
policy: PanelAccessPolicy{},
remote: "198.51.100.9:44321",
allowed: true,
current: "198.51.100.9",
},
{
name: "direct CIDR match",
policy: base,
remote: "192.0.2.25:44321",
allowed: true,
current: "192.0.2.25",
},
{
name: "direct denied",
policy: base,
remote: "198.51.100.9:44321",
allowed: false,
current: "198.51.100.9",
},
{
name: "spoofed forwarding header ignored",
policy: base,
remote: "198.51.100.9:44321",
headers: ForwardedClientHeaders{
ForwardedFor: "192.0.2.10",
},
allowed: false,
current: "198.51.100.9",
},
{
name: "trusted proxy forwards allowed source",
policy: base,
remote: "10.0.0.1:44321",
headers: ForwardedClientHeaders{
ForwardedFor: "192.0.2.10",
},
allowed: true,
current: "192.0.2.10",
usedForwarded: true,
},
{
name: "trusted proxy forwards denied source",
policy: base,
remote: "10.0.0.1:44321",
headers: ForwardedClientHeaders{
RealIP: "198.51.100.20",
},
allowed: false,
current: "198.51.100.20",
usedForwarded: true,
},
{
name: "direct loopback recovery",
policy: base,
remote: "127.0.0.1:44321",
allowed: true,
current: "127.0.0.1",
usedForwarded: false,
},
{
name: "trusted loopback proxy is enforced",
policy: base,
remote: "127.0.0.1:44321",
headers: ForwardedClientHeaders{
ForwardedFor: "198.51.100.20",
},
allowed: false,
current: "198.51.100.20",
usedForwarded: true,
},
{
name: "IPv6 source",
policy: base,
remote: "[2001:db8::88]:44321",
allowed: true,
current: "2001:db8::88",
},
{
name: "trusted proxy chain",
policy: base,
remote: "10.0.0.1:44321",
headers: ForwardedClientHeaders{
ForwardedFor: "192.0.2.70, 10.0.0.1",
},
allowed: true,
current: "192.0.2.70",
usedForwarded: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := EvaluatePanelAccess(tt.policy, tt.remote, tt.headers)
if got.Allowed != tt.allowed || got.CurrentSource != tt.current || got.UsedForwarded != tt.usedForwarded {
t.Fatalf("EvaluatePanelAccess() = %#v", got)
}
})
}
}
+114
View File
@@ -0,0 +1,114 @@
package config
import (
"path/filepath"
"testing"
)
func TestNormalizeStoragePoolsReplacesPersistedCustomPath(t *testing.T) {
previousConfig := AppConfig
t.Cleanup(func() { AppConfig = previousConfig })
mountPoint := filepath.Join(t.TempDir(), "data")
AppConfig = &ClicdConfig{StoragePools: []StoragePool{{
ID: "data",
Name: "data",
Path: filepath.Join(t.TempDir(), "uncontrolled"),
MountPoint: mountPoint,
Enabled: true,
}}}
if !normalizeStoragePools() {
t.Fatal("expected custom path normalization to report a change")
}
want := managedStoragePoolPath(mountPoint)
if got := AppConfig.StoragePools[0].Path; got != want {
t.Fatalf("normalized path = %q, want %q", got, want)
}
}
func TestSelectStoragePoolForContent(t *testing.T) {
previousConfig := AppConfig
previousProbe := probeStoragePoolFreeBytes
t.Cleanup(func() {
AppConfig = previousConfig
probeStoragePoolFreeBytes = previousProbe
})
AppConfig = &ClicdConfig{StoragePools: []StoragePool{
{
ID: "primary",
Path: "/primary",
ContentTypes: []string{StorageContentLXC},
DefaultContents: []string{StorageContentLXC},
Enabled: true,
},
{
ID: "large",
Path: "/large",
ContentTypes: []string{StorageContentLXC},
Enabled: true,
},
{
ID: "small",
Path: "/small",
ContentTypes: []string{StorageContentLXC},
Enabled: true,
},
}}
free := map[string]int64{
"primary": 20 * 1024 * 1024 * 1024,
"large": 50 * 1024 * 1024 * 1024,
"small": 10 * 1024 * 1024 * 1024,
}
probeStoragePoolFreeBytes = func(pool StoragePool) (int64, bool) {
value, ok := free[pool.ID]
return value, ok
}
pool, err := SelectStoragePoolForContent(StorageContentLXC, "", 5*1024*1024*1024)
if err != nil {
t.Fatal(err)
}
if pool.ID != "primary" {
t.Fatalf("selected %q, want configured default primary", pool.ID)
}
free["primary"] = 128 * 1024 * 1024
pool, err = SelectStoragePoolForContent(StorageContentLXC, "", 5*1024*1024*1024)
if err != nil {
t.Fatal(err)
}
if pool.ID != "large" {
t.Fatalf("selected %q, want largest fallback pool", pool.ID)
}
pool, err = SelectStoragePoolForContent(StorageContentLXC, "small", 5*1024*1024*1024)
if err != nil {
t.Fatal(err)
}
if pool.ID != "small" {
t.Fatalf("selected %q, want requested pool", pool.ID)
}
}
func TestSelectStoragePoolRequiresEnabledContent(t *testing.T) {
previousConfig := AppConfig
previousProbe := probeStoragePoolFreeBytes
t.Cleanup(func() {
AppConfig = previousConfig
probeStoragePoolFreeBytes = previousProbe
})
AppConfig = &ClicdConfig{StoragePools: []StoragePool{{
ID: "primary",
Path: "/primary",
ContentTypes: []string{StorageContentLXC},
Enabled: true,
}}}
probeStoragePoolFreeBytes = func(StoragePool) (int64, bool) { return 100 * 1024 * 1024 * 1024, true }
if _, err := SelectStoragePoolForContent(StorageContentSnapshots, "", 0); err == nil {
t.Fatal("expected snapshots selection to fail when no pool enables snapshots")
}
}
File diff suppressed because it is too large Load Diff
+60 -1
View File
@@ -63,9 +63,37 @@ func TestSQLiteConfigMigratesLegacyJSONAndPersists(t *testing.T) {
ContainerName: "ct2",
Status: "pending",
CreatedAt: "2026-06-07 17:29:02",
Config: `{"name":"ct2","template_id":"debian-12","vcpu":1,"ram_mb":512,"disk_gb":5,"extra_ports":[80,443],"assign_ipv6":true}`,
Config: `{"name":"ct2","template_id":"debian-12","vcpu":1,"ram_mb":512,"disk_gb":5,"extra_ports":[80,443],"nat_port_mappings":[{"host_port":30080,"container_port":80,"protocol":"tcp","description":"HTTP"}],"management_port":30022,"assign_ipv6":true}`,
}},
EnabledImages: []string{"debian-12"},
CustomKVMImages: []CustomKVMImage{{
ID: "custom-kvm-test",
Name: "Test Cloud Image",
Description: "third-party image",
Distro: "ubuntu",
Release: "noble",
Arch: "amd64",
URL: "https://images.example.test/ubuntu.qcow2",
Provisioner: KVMProvisionerLinuxCloudInit,
SHA256: strings.Repeat("a", 64),
CreatedAt: "2026-07-26 10:00:00",
}},
CustomLXCImages: []CustomLXCImage{{
ID: "custom-lxc-test",
Name: "Test Rootfs",
Description: "third-party LXC image",
Distro: "alpine",
Release: "3.21",
Arch: "amd64",
URL: "https://images.example.test/alpine-rootfs.tar.xz",
SHA256: strings.Repeat("b", 64),
CreatedAt: "2026-07-26 10:00:00",
}},
PanelAccessPolicy: PanelAccessPolicy{
Enabled: true,
AllowedSources: []string{"192.0.2.0/24"},
TrustedProxies: []string{"127.0.0.1"},
},
Snapshots: []Snapshot{{
ID: "snap-1",
ContainerID: 1,
@@ -93,11 +121,30 @@ func TestSQLiteConfigMigratesLegacyJSONAndPersists(t *testing.T) {
if len(cfg.Tasks) != 1 || !strings.Contains(cfg.Tasks[0].Config, `"extra_ports":[80,443]`) {
t.Fatalf("task config was not restored from sqlite columns: %+v", cfg.Tasks)
}
if !strings.Contains(cfg.Tasks[0].Config, `"nat_port_mappings":[{"host_port":30080,"container_port":80`) {
t.Fatalf("task NAT mappings were not restored from sqlite: %+v", cfg.Tasks)
}
if !strings.Contains(cfg.Tasks[0].Config, `"management_port":30022`) {
t.Fatalf("task management port was not restored from sqlite: %+v", cfg.Tasks)
}
if cfg.TaskConcurrency != DefaultTaskConcurrency {
t.Fatalf("legacy task concurrency = %d, want default %d", cfg.TaskConcurrency, DefaultTaskConcurrency)
}
if !cfg.PanelAccessPolicy.Enabled || len(cfg.PanelAccessPolicy.AllowedSources) != 1 {
t.Fatalf("legacy panel access policy was not migrated: %+v", cfg.PanelAccessPolicy)
}
if len(cfg.CustomKVMImages) != 1 || cfg.CustomKVMImages[0].ID != "custom-kvm-test" {
t.Fatalf("legacy custom KVM images were not migrated: %+v", cfg.CustomKVMImages)
}
if len(cfg.CustomLXCImages) != 1 || cfg.CustomLXCImages[0].ID != "custom-lxc-test" {
t.Fatalf("legacy custom LXC images were not migrated: %+v", cfg.CustomLXCImages)
}
if _, err := os.Stat(filepath.Join(dir, "config.db")); err != nil {
t.Fatalf("sqlite database was not created: %v", err)
}
cfg.Containers[0].Status = "stopped"
cfg.TaskConcurrency = 6
if err := SaveConfig(); err != nil {
t.Fatal(err)
}
@@ -111,6 +158,18 @@ func TestSQLiteConfigMigratesLegacyJSONAndPersists(t *testing.T) {
if got := cfg.Containers[0].Status; got != "stopped" {
t.Fatalf("expected sqlite value to win after migration, got %q", got)
}
if got := cfg.TaskConcurrency; got != 6 {
t.Fatalf("persisted task concurrency = %d, want 6", got)
}
if !cfg.PanelAccessPolicy.Enabled || cfg.PanelAccessPolicy.AllowedSources[0] != "192.0.2.0/24" {
t.Fatalf("persisted panel access policy = %+v", cfg.PanelAccessPolicy)
}
if len(cfg.CustomKVMImages) != 1 || cfg.CustomKVMImages[0].SHA256 != strings.Repeat("a", 64) {
t.Fatalf("persisted custom KVM images = %+v", cfg.CustomKVMImages)
}
if len(cfg.CustomLXCImages) != 1 || cfg.CustomLXCImages[0].SHA256 != strings.Repeat("b", 64) {
t.Fatalf("persisted custom LXC images = %+v", cfg.CustomLXCImages)
}
}
func resetConfigStoreForTest(t *testing.T) {
File diff suppressed because it is too large Load Diff
+156
View File
@@ -3,7 +3,14 @@ package kvm
import (
"crypto/ed25519"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/xml"
"os"
"path/filepath"
"reflect"
"runtime"
"strings"
"testing"
"clicd/internal/config"
@@ -11,6 +18,84 @@ import (
"golang.org/x/crypto/ssh"
)
func TestImagePathUsesAllowlistedImageID(t *testing.T) {
for _, id := range []string{"", ".", "..", "../../etc/passwd", `..\\..\\windows`, "/absolute", "unknown-image"} {
if got := filepath.Base(ImagePath(id)); got != "__invalid_image_id__.qcow2" {
t.Fatalf("ImagePath(%q) basename = %q", id, got)
}
}
validID := GetImages()[0].ID
if got := filepath.Base(ImagePath(validID)); got != validID+".qcow2" {
t.Fatalf("ImagePath(%q) basename = %q", validID, got)
}
}
func TestWindows11ImageDefinition(t *testing.T) {
image := FindImage("kvm-windows-11")
if image == nil {
t.Fatal("Windows 11 image is missing from the amd64 image list")
}
if image.Distro != "windows" || image.Release != "11" || image.Arch != "amd64" {
t.Fatalf("Windows 11 image metadata = %+v", image)
}
if !strings.Contains(image.URL, "microsoft.com/fwlink/") {
t.Fatalf("Windows 11 image does not use an official Microsoft URL: %s", image.URL)
}
if got := filepath.Base(ImagePath(image.ID)); got != "kvm-windows-11.iso" {
t.Fatalf("Windows 11 image basename = %q", got)
}
}
func TestWindows11UnattendAddsCompatibilityChecksOnlyForWindows11(t *testing.T) {
windows11 := windowsAutounattendXML("win11-test", "Password123!", true)
windows10 := windowsAutounattendXML("win10-test", "Password123!", false)
for _, key := range []string{"BypassTPMCheck", "BypassSecureBootCheck", "BypassCPUCheck"} {
if !strings.Contains(windows11, key) {
t.Fatalf("Windows 11 unattend is missing %s", key)
}
if strings.Contains(windows10, key) {
t.Fatalf("Windows 10 unattend unexpectedly contains %s", key)
}
}
var document struct {
XMLName xml.Name
}
if err := xml.Unmarshal([]byte(windows11), &document); err != nil {
t.Fatalf("Windows 11 unattend XML is invalid: %v", err)
}
}
func TestWindowsMinimumResources(t *testing.T) {
if cpu, ram, disk := windowsMinimumResources("kvm-windows-11"); cpu != 2 || ram != 4096 || disk != 64 {
t.Fatalf("Windows 11 minimums = %v vCPU, %d MB, %d GB", cpu, ram, disk)
}
if cpu, ram, disk := windowsMinimumResources("kvm-windows-10"); cpu != 1 || ram != 2048 || disk != 30 {
t.Fatalf("Windows 10 minimums = %v vCPU, %d MB, %d GB", cpu, ram, disk)
}
}
func TestLibvirtNetworkActiveParsesCLocaleOutput(t *testing.T) {
tests := []struct {
name string
info string
want bool
}{
{name: "active", info: "Name: default\nActive: yes\n", want: true},
{name: "spacing and case", info: " Active : YES \r\n", want: true},
{name: "inactive", info: "Name: default\nActive: no\n", want: false},
{name: "missing field", info: "Name: default\nAutostart: yes\n", want: false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := libvirtNetworkActive(tc.info); got != tc.want {
t.Fatalf("libvirtNetworkActive(%q) = %v, want %v", tc.info, got, tc.want)
}
})
}
}
func TestChpasswdStdinPreservesShellMetacharacters(t *testing.T) {
password := `pa'";$(touch /tmp/pwned); echo #\\word`
got, err := chpasswdStdin("root", password)
@@ -81,6 +166,77 @@ func TestVerifyKVMHostKeyCapturesAndRejectsMismatch(t *testing.T) {
}
}
func TestGetImagesIncludesHostArchitectureCustomImage(t *testing.T) {
previous := config.AppConfig
t.Cleanup(func() { config.AppConfig = previous })
config.AppConfig = &config.ClicdConfig{
CustomKVMImages: []config.CustomKVMImage{
{
ID: "custom-kvm-linux",
Name: "Custom Linux",
Distro: "ubuntu",
Release: "noble",
Arch: runtime.GOARCH,
URL: "https://example.test/linux.qcow2",
Provisioner: config.KVMProvisionerLinuxCloudInit,
},
{
ID: "custom-kvm-other-arch",
Name: "Other Architecture",
Distro: "ubuntu",
Release: "noble",
Arch: "not-" + runtime.GOARCH,
URL: "https://example.test/other.qcow2",
Provisioner: config.KVMProvisionerLinuxCloudInit,
},
},
}
image := FindImage("custom-kvm-linux")
if image == nil || !image.Custom || image.Provisioner != config.KVMProvisionerLinuxCloudInit {
t.Fatalf("custom image was not exposed correctly: %+v", image)
}
if FindImage("custom-kvm-other-arch") != nil {
t.Fatal("custom image for another architecture was exposed")
}
}
func TestCustomWindowsProvisionerControlsImageType(t *testing.T) {
previous := config.AppConfig
t.Cleanup(func() { config.AppConfig = previous })
config.AppConfig = &config.ClicdConfig{CustomKVMImages: []config.CustomKVMImage{{
ID: "custom-kvm-windows",
Name: "Custom Windows",
Distro: "windows",
Release: "11",
Arch: runtime.GOARCH,
URL: "https://example.test/windows.iso",
Provisioner: config.KVMProvisionerWindows11,
}}}
if !IsWindowsImage("custom-kvm-windows") || !IsWindows11Image("custom-kvm-windows") {
t.Fatal("custom Windows 11 provisioner was not recognized")
}
if ext := filepath.Ext(ImagePath("custom-kvm-windows")); ext != ".iso" {
t.Fatalf("custom Windows image extension = %q, want .iso", ext)
}
}
func TestVerifyFileSHA256(t *testing.T) {
path := filepath.Join(t.TempDir(), "image")
content := []byte("clicd custom image")
if err := os.WriteFile(path, content, 0600); err != nil {
t.Fatal(err)
}
sum := sha256.Sum256(content)
if err := verifyFileSHA256(path, hex.EncodeToString(sum[:])); err != nil {
t.Fatalf("valid checksum failed: %v", err)
}
if err := verifyFileSHA256(path, strings.Repeat("0", 64)); err == nil {
t.Fatal("invalid checksum unexpectedly passed")
}
}
func testSSHPublicKey(t *testing.T) ssh.PublicKey {
t.Helper()
_, privateKey, err := ed25519.GenerateKey(rand.Reader)
+145 -4
View File
@@ -1,7 +1,11 @@
package kvm
import (
"os"
"path/filepath"
"runtime"
"clicd/internal/config"
)
type Image struct {
@@ -13,9 +17,40 @@ type Image struct {
Description string `json:"description"`
URL string `json:"url"`
Desktop string `json:"desktop,omitempty"`
Provisioner string `json:"provisioner,omitempty"`
SHA256 string `json:"sha256,omitempty"`
Custom bool `json:"custom,omitempty"`
}
func GetImages() []Image {
var images []Image
switch runtime.GOARCH {
case "arm64":
images = arm64Images()
default:
images = amd64Images()
}
for _, custom := range config.ListCustomKVMImages() {
if custom.Arch != runtime.GOARCH {
continue
}
images = append(images, Image{
ID: custom.ID,
Name: custom.Name,
Distro: custom.Distro,
Release: custom.Release,
Arch: custom.Arch,
Description: custom.Description,
URL: custom.URL,
Provisioner: custom.Provisioner,
SHA256: custom.SHA256,
Custom: true,
})
}
return images
}
func amd64Images() []Image {
return []Image{
{
ID: "kvm-ubuntu-noble", Name: "Ubuntu 24.04 KVM",
@@ -36,12 +71,25 @@ func GetImages() []Image {
Description: "Ubuntu 22.04 LTS cloud image for KVM",
URL: "https://cloud-images.ubuntu.com/jammy/current/jammy-server-cloudimg-amd64.img",
},
{
ID: "kvm-debian-trixie", Name: "Debian 13 KVM",
Distro: "debian", Release: "trixie", Arch: "amd64",
Description: "Debian 13 generic cloud image for KVM",
URL: "https://cloud.debian.org/images/cloud/trixie/latest/debian-13-genericcloud-amd64.qcow2",
},
{
ID: "kvm-debian-bookworm", Name: "Debian 12 KVM",
Distro: "debian", Release: "bookworm", Arch: "amd64",
Description: "Debian 12 generic cloud image for KVM",
URL: "https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-amd64.qcow2",
},
{
ID: "kvm-debian-trixie-xfce", Name: "Debian 13 XFCE KVM",
Distro: "debian", Release: "trixie", Arch: "amd64",
Description: "Debian 13 generic cloud image with XFCE desktop provisioned via cloud-init",
URL: "https://cloud.debian.org/images/cloud/trixie/latest/debian-13-genericcloud-amd64.qcow2",
Desktop: "xfce",
},
{
ID: "kvm-debian-bookworm-xfce", Name: "Debian 12 XFCE KVM",
Distro: "debian", Release: "bookworm", Arch: "amd64",
@@ -85,6 +133,12 @@ func GetImages() []Image {
Description: "Rocky Linux 9 GenericCloud image for KVM",
URL: "https://dl.rockylinux.org/pub/rocky/9/images/x86_64/Rocky-9-GenericCloud-Base.latest.x86_64.qcow2",
},
{
ID: "kvm-windows-11", Name: "Windows 11 KVM",
Distro: "windows", Release: "11", Arch: "amd64",
Description: "Windows 11 Enterprise LTSC 2024 Evaluation",
URL: "https://go.microsoft.com/fwlink/?clcid=0x409&country=us&culture=en-us&linkid=2289029",
},
{
ID: "kvm-windows-10", Name: "Windows 10 KVM",
Distro: "windows", Release: "10", Arch: "amd64",
@@ -94,6 +148,59 @@ func GetImages() []Image {
}
}
func arm64Images() []Image {
return []Image{
{
ID: "kvm-ubuntu-noble", Name: "Ubuntu 24.04 KVM",
Distro: "ubuntu", Release: "noble", Arch: "arm64",
Description: "Ubuntu 24.04 LTS cloud image for ARM64 KVM",
URL: "https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-arm64.img",
},
{
ID: "kvm-ubuntu-jammy", Name: "Ubuntu 22.04 KVM",
Distro: "ubuntu", Release: "jammy", Arch: "arm64",
Description: "Ubuntu 22.04 LTS cloud image for ARM64 KVM",
URL: "https://cloud-images.ubuntu.com/jammy/current/jammy-server-cloudimg-arm64.img",
},
{
ID: "kvm-debian-trixie", Name: "Debian 13 KVM",
Distro: "debian", Release: "trixie", Arch: "arm64",
Description: "Debian 13 generic cloud image for ARM64 KVM",
URL: "https://cloud.debian.org/images/cloud/trixie/latest/debian-13-genericcloud-arm64.qcow2",
},
{
ID: "kvm-debian-bookworm", Name: "Debian 12 KVM",
Distro: "debian", Release: "bookworm", Arch: "arm64",
Description: "Debian 12 generic cloud image for ARM64 KVM",
URL: "https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-arm64.qcow2",
},
{
ID: "kvm-debian-bullseye", Name: "Debian 11 KVM",
Distro: "debian", Release: "bullseye", Arch: "arm64",
Description: "Debian 11 generic cloud image for ARM64 KVM",
URL: "https://cloud.debian.org/images/cloud/bullseye/latest/debian-11-genericcloud-arm64.qcow2",
},
{
ID: "kvm-centos-9-stream", Name: "CentOS Stream 9 KVM",
Distro: "centos", Release: "9-stream", Arch: "arm64",
Description: "CentOS Stream 9 GenericCloud image for ARM64 KVM",
URL: "https://cloud.centos.org/centos/9-stream/aarch64/images/CentOS-Stream-GenericCloud-9-latest.aarch64.qcow2",
},
{
ID: "kvm-fedora-44", Name: "Fedora 44 KVM",
Distro: "fedora", Release: "44", Arch: "arm64",
Description: "Fedora 44 GenericCloud image for ARM64 KVM",
URL: "https://download.fedoraproject.org/pub/fedora/linux/releases/44/Cloud/aarch64/images/Fedora-Cloud-Base-Generic-44-1.7.aarch64.qcow2",
},
{
ID: "kvm-rockylinux-9", Name: "Rocky Linux 9 KVM",
Distro: "rockylinux", Release: "9", Arch: "arm64",
Description: "Rocky Linux 9 GenericCloud image for ARM64 KVM",
URL: "https://dl.rockylinux.org/pub/rocky/9/images/aarch64/Rocky-9-GenericCloud-Base.latest.aarch64.qcow2",
},
}
}
func FindImage(id string) *Image {
for _, image := range GetImages() {
if image.ID == id {
@@ -104,22 +211,56 @@ func FindImage(id string) *Image {
}
func CacheDir() string {
if pool := config.PreferredStoragePoolForContent(config.StorageContentImages); pool != nil {
return filepath.Join(pool.Path, "images", "kvm")
}
return filepath.Join(BaseDir(), "images")
}
func ImagePath(id string) string {
img := FindImage(id)
ext := ".qcow2"
if img != nil && img.Distro == "windows" {
safeID := "__invalid_image_id__"
if img != nil {
safeID = img.ID
}
if img != nil && img.IsWindows() {
ext = ".iso"
}
return filepath.Join(CacheDir(), id+ext)
fileName := safeID + ext
for _, pool := range config.StoragePoolsForContent(config.StorageContentImages) {
candidate := filepath.Join(pool.Path, "images", "kvm", fileName)
if info, err := os.Stat(candidate); err == nil && !info.IsDir() {
return candidate
}
}
legacy := filepath.Join("/var/lib/clicd/kvm/images", fileName)
if info, err := os.Stat(legacy); err == nil && !info.IsDir() {
return legacy
}
return filepath.Join(CacheDir(), fileName)
}
// IsWindowsImage returns true if the image distro is "windows".
func (image Image) IsWindows() bool {
return image.Provisioner == config.KVMProvisionerWindows10 ||
image.Provisioner == config.KVMProvisionerWindows11 ||
(image.Provisioner == "" && image.Distro == "windows")
}
func (image Image) IsWindows11() bool {
return image.Provisioner == config.KVMProvisionerWindows11 ||
(image.Provisioner == "" && image.Distro == "windows" && image.Release == "11")
}
// IsWindowsImage returns true if the image uses Windows unattended installation.
func IsWindowsImage(id string) bool {
img := FindImage(id)
return img != nil && img.Distro == "windows"
return img != nil && img.IsWindows()
}
func IsWindows11Image(id string) bool {
img := FindImage(id)
return img != nil && img.IsWindows11()
}
func virtioWinISOPath() string {
+297
View File
@@ -0,0 +1,297 @@
package lxc
import (
"bufio"
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"os"
"os/exec"
"path"
"path/filepath"
"strings"
"time"
"clicd/internal/safehttp"
)
type CustomImageDownloadProgress struct {
Stage string
DownloadedBytes int64
TotalBytes int64
Percent int
}
type CustomImageDownloadProgressFunc func(CustomImageDownloadProgress)
func CustomImagePath(id string) string {
template := FindTemplate(id)
if template == nil || !template.Custom {
return filepath.Join("/var/cache/lxc/download/custom", "__invalid_image_id__", "rootfs.tar")
}
return filepath.Join("/var/cache/lxc/download/custom", template.ID, "rootfs.tar")
}
func CustomImageDownloadedInfo(id string) (bool, int64) {
info, err := os.Stat(CustomImagePath(id))
if err != nil || info.IsDir() {
return false, 0
}
return true, info.Size()
}
func DeleteCustomImage(id string) error {
template := FindTemplate(id)
if template == nil || !template.Custom {
return fmt.Errorf("custom LXC image not found")
}
return os.RemoveAll(filepath.Dir(CustomImagePath(id)))
}
func DownloadCustomImageWithProgress(ctx context.Context, template Template, progress CustomImageDownloadProgressFunc) error {
if !template.Custom {
return fmt.Errorf("template is not a custom LXC image")
}
target := CustomImagePath(template.ID)
if ok, _ := CustomImageDownloadedInfo(template.ID); ok {
return nil
}
if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
return err
}
tmp := target + ".tmp"
_ = os.Remove(tmp)
if err := downloadCustomRootfs(ctx, template.URL, tmp, progress); err != nil {
_ = os.Remove(tmp)
return err
}
if err := ctx.Err(); err != nil {
_ = os.Remove(tmp)
return err
}
if template.SHA256 != "" {
if err := verifyCustomRootfsSHA256(tmp, template.SHA256); err != nil {
_ = os.Remove(tmp)
return err
}
}
if progress != nil {
progress(CustomImageDownloadProgress{Stage: "validating", Percent: 100})
}
if err := ValidateCustomRootfsArchive(tmp); err != nil {
_ = os.Remove(tmp)
return err
}
if err := os.Rename(tmp, target); err != nil {
_ = os.Remove(tmp)
return err
}
return os.Chmod(target, 0644)
}
func downloadCustomRootfs(ctx context.Context, sourceURL, target string, progress CustomImageDownloadProgressFunc) error {
response, err := safehttp.Get(ctx, sourceURL, "CLICD/1.0 LXC image downloader", 30*time.Minute)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode < 200 || response.StatusCode >= 300 {
return fmt.Errorf("download failed: %s", response.Status)
}
file, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600)
if err != nil {
return err
}
defer file.Close()
total := response.ContentLength
buffer := make([]byte, 128*1024)
var downloaded int64
for {
count, readErr := response.Body.Read(buffer)
if count > 0 {
if _, err := file.Write(buffer[:count]); err != nil {
return err
}
downloaded += int64(count)
if progress != nil {
percent := 0
if total > 0 {
percent = int(downloaded * 100 / total)
if percent > 100 {
percent = 100
}
}
progress(CustomImageDownloadProgress{
Stage: "downloading",
DownloadedBytes: downloaded,
TotalBytes: total,
Percent: percent,
})
}
}
if readErr == io.EOF {
break
}
if readErr != nil {
return readErr
}
}
return file.Sync()
}
func verifyCustomRootfsSHA256(filePath, expected string) error {
file, err := os.Open(filePath)
if err != nil {
return err
}
defer file.Close()
hash := sha256.New()
if _, err := io.Copy(hash, file); err != nil {
return err
}
actual := hex.EncodeToString(hash.Sum(nil))
if !strings.EqualFold(actual, strings.TrimSpace(expected)) {
return fmt.Errorf("SHA-256 mismatch: expected %s, got %s", expected, actual)
}
return nil
}
func ValidateCustomRootfsArchive(archivePath string) error {
command := exec.Command("tar", "-tf", archivePath)
stdout, err := command.StdoutPipe()
if err != nil {
return err
}
var stderr strings.Builder
command.Stderr = &stderr
if err := command.Start(); err != nil {
return fmt.Errorf("failed to inspect rootfs archive: %v", err)
}
scanner := bufio.NewScanner(stdout)
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
entries := make([]string, 0, 4096)
for scanner.Scan() {
if len(entries) >= 2_000_000 {
_ = command.Process.Kill()
return fmt.Errorf("rootfs archive contains too many entries")
}
entries = append(entries, scanner.Text())
}
scanErr := scanner.Err()
waitErr := command.Wait()
if scanErr != nil {
return fmt.Errorf("failed to read rootfs archive: %v", scanErr)
}
if waitErr != nil {
return fmt.Errorf("invalid rootfs archive: %v, output: %s", waitErr, strings.TrimSpace(stderr.String()))
}
return validateCustomRootfsEntries(entries)
}
func validateCustomRootfsEntries(entries []string) error {
hasInit := false
for _, entry := range entries {
entry = strings.TrimSpace(strings.ReplaceAll(entry, "\\", "/"))
entry = strings.TrimPrefix(entry, "./")
if entry == "" || entry == "." {
continue
}
if strings.HasPrefix(entry, "/") {
return fmt.Errorf("rootfs archive contains an absolute path: %s", entry)
}
clean := path.Clean(entry)
if clean == ".." || strings.HasPrefix(clean, "../") {
return fmt.Errorf("rootfs archive contains path traversal: %s", entry)
}
switch strings.TrimSuffix(clean, "/") {
case "sbin/init", "usr/lib/systemd/systemd", "lib/systemd/systemd", "bin/busybox", "bin/sh":
hasInit = true
}
}
if len(entries) == 0 {
return fmt.Errorf("rootfs archive is empty")
}
if !hasInit {
return fmt.Errorf("rootfs archive does not contain a supported init")
}
return nil
}
func ExtractCustomRootfs(templateID, destination string) error {
template := FindTemplate(templateID)
if template == nil || !template.Custom {
return fmt.Errorf("custom LXC image not found: %s", templateID)
}
archive := CustomImagePath(template.ID)
if ok, _ := CustomImageDownloadedInfo(template.ID); !ok {
return fmt.Errorf("custom LXC image is not downloaded: %s", templateID)
}
if err := ValidateCustomRootfsArchive(archive); err != nil {
return err
}
if err := os.MkdirAll(destination, 0755); err != nil {
return err
}
output, err := exec.Command("tar", "-xpf", archive, "-C", destination).CombinedOutput()
if err != nil {
return fmt.Errorf("failed to extract custom LXC rootfs: %v, output: %s", err, strings.TrimSpace(string(output)))
}
if err := secureExtractedRootfs(destination); err != nil {
return err
}
if !rootfsHasInit(destination) {
return fmt.Errorf("extracted custom LXC rootfs is invalid: init not found")
}
return nil
}
func secureExtractedRootfs(root string) error {
root, err := filepath.Abs(root)
if err != nil {
return err
}
return filepath.WalkDir(root, func(filePath string, entry os.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
info, err := entry.Info()
if err != nil {
return err
}
if info.Mode()&os.ModeSymlink == 0 {
return nil
}
target, err := os.Readlink(filePath)
if err != nil {
return err
}
var resolved string
if filepath.IsAbs(target) {
resolved = filepath.Join(root, strings.TrimLeft(filepath.ToSlash(target), "/"))
relative, err := filepath.Rel(filepath.Dir(filePath), resolved)
if err != nil {
return err
}
if err := os.Remove(filePath); err != nil {
return err
}
if err := os.Symlink(relative, filePath); err != nil {
return err
}
} else {
resolved = filepath.Join(filepath.Dir(filePath), target)
}
relativeToRoot, err := filepath.Rel(root, filepath.Clean(resolved))
if err != nil {
return err
}
if relativeToRoot == ".." || strings.HasPrefix(relativeToRoot, ".."+string(os.PathSeparator)) {
return fmt.Errorf("rootfs symlink escapes the archive root: %s -> %s", filePath, target)
}
return nil
})
}
@@ -0,0 +1,61 @@
package lxc
import (
"path/filepath"
"runtime"
"testing"
"clicd/internal/config"
)
func TestGetTemplatesIncludesHostArchitectureCustomLXCImage(t *testing.T) {
previous := config.AppConfig
t.Cleanup(func() { config.AppConfig = previous })
config.AppConfig = &config.ClicdConfig{CustomLXCImages: []config.CustomLXCImage{
{
ID: "custom-lxc-host", Name: "Host Rootfs", Distro: "alpine",
Release: "3.21", Arch: runtime.GOARCH, URL: "https://example.test/rootfs.tar.xz",
},
{
ID: "custom-lxc-other", Name: "Other Rootfs", Distro: "alpine",
Release: "3.21", Arch: "not-" + runtime.GOARCH, URL: "https://example.test/other.tar.xz",
},
}}
template := FindTemplate("custom-lxc-host")
if template == nil || !template.Custom || template.URL == "" {
t.Fatalf("custom LXC template was not exposed correctly: %+v", template)
}
if FindTemplate("custom-lxc-other") != nil {
t.Fatal("custom LXC template for another architecture was exposed")
}
}
func TestCustomImagePathUsesAllowlistedID(t *testing.T) {
previous := config.AppConfig
t.Cleanup(func() { config.AppConfig = previous })
config.AppConfig = &config.ClicdConfig{}
for _, id := range []string{"", ".", "..", "../../etc/passwd", "/absolute", "unknown"} {
got := filepath.ToSlash(CustomImagePath(id))
if filepath.Base(filepath.Dir(got)) != "__invalid_image_id__" {
t.Fatalf("CustomImagePath(%q) = %q", id, got)
}
}
}
func TestValidateCustomRootfsEntries(t *testing.T) {
if err := validateCustomRootfsEntries([]string{"./etc/", "./bin/", "./bin/sh"}); err != nil {
t.Fatalf("valid rootfs entries failed: %v", err)
}
for _, entries := range [][]string{
{},
{"etc/passwd"},
{"/etc/passwd", "bin/sh"},
{"../../etc/passwd", "bin/sh"},
} {
if err := validateCustomRootfsEntries(entries); err == nil {
t.Fatalf("unsafe rootfs entries unexpectedly passed: %#v", entries)
}
}
}
File diff suppressed because it is too large Load Diff
+1280 -202
View File
File diff suppressed because it is too large Load Diff
+424
View File
@@ -6,6 +6,8 @@ import (
"reflect"
"strings"
"testing"
"clicd/internal/config"
)
func TestRootfsCommandAddsSeparatorForAllowedCommand(t *testing.T) {
@@ -27,6 +29,335 @@ func TestRootfsCommandAddsSeparatorForAllowedCommand(t *testing.T) {
}
}
func TestNormalizeCreateNATMappingsSupportsDifferentHostAndContainerPorts(t *testing.T) {
previous := config.AppConfig
t.Cleanup(func() { config.AppConfig = previous })
config.AppConfig = &config.ClicdConfig{NATPortStart: 20000, NATPortEnd: 65535}
cfg := ContainerConfig{
PortMappingCount: 2,
NATPortMappings: []config.PortMapping{{
HostPort: 30080,
ContainerPort: 80,
Protocol: "TCP",
}},
}
if err := cfg.NormalizeCreateNATMappings(); err != nil {
t.Fatal(err)
}
if cfg.PortMappingCount != 2 || len(cfg.NATPortMappings) != 1 {
t.Fatalf("normalized config = %+v", cfg)
}
mapping := cfg.NATPortMappings[0]
if mapping.HostPort != 30080 || mapping.ContainerPort != 80 || mapping.Protocol != "tcp" {
t.Fatalf("normalized mapping = %+v", mapping)
}
container := &config.Container{
ID: -1,
PortMappings: []config.PortMapping{{
HostPort: 22000,
ContainerPort: 22,
Protocol: "tcp",
Description: "SSH",
}},
}
mappings, err := SetupCreatePortMappings(container, cfg)
if err != nil {
t.Fatal(err)
}
if len(mappings) != 2 || mappings[1].HostPort != 30080 || mappings[1].ContainerPort != 80 {
t.Fatalf("created mappings = %+v", mappings)
}
}
func TestNormalizeCreateNATMappingsKeepsLegacyExtraPortsCompatible(t *testing.T) {
previous := config.AppConfig
t.Cleanup(func() { config.AppConfig = previous })
config.AppConfig = &config.ClicdConfig{NATPortStart: 20000, NATPortEnd: 65535}
cfg := ContainerConfig{ExtraPorts: []int{30080, 30443}}
if err := cfg.NormalizeCreateNATMappings(); err != nil {
t.Fatal(err)
}
if len(cfg.ExtraPorts) != 0 || len(cfg.NATPortMappings) != 2 {
t.Fatalf("legacy ports were not converted: %+v", cfg)
}
for _, mapping := range cfg.NATPortMappings {
if mapping.HostPort != mapping.ContainerPort {
t.Fatalf("legacy mapping changed semantics: %+v", mapping)
}
}
}
func TestNormalizeCreateNATMappingsRejectsDuplicateHostPort(t *testing.T) {
previous := config.AppConfig
t.Cleanup(func() { config.AppConfig = previous })
config.AppConfig = &config.ClicdConfig{NATPortStart: 20000, NATPortEnd: 65535}
cfg := ContainerConfig{NATPortMappings: []config.PortMapping{
{HostPort: 30080, ContainerPort: 80, Protocol: "tcp"},
{HostPort: 30080, ContainerPort: 8080, Protocol: "tcp"},
}}
if err := cfg.NormalizeCreateNATMappings(); err == nil {
t.Fatal("duplicate host port was accepted")
}
}
func TestNormalizeCreateNATMappingsRejectsManagementPortConflict(t *testing.T) {
previous := config.AppConfig
t.Cleanup(func() { config.AppConfig = previous })
config.AppConfig = &config.ClicdConfig{NATPortStart: 20000, NATPortEnd: 65535}
cfg := ContainerConfig{
ManagementPort: 30022,
NATPortMappings: []config.PortMapping{{
HostPort: 30022,
ContainerPort: 8080,
Protocol: "tcp",
}},
}
if err := cfg.NormalizeCreateNATMappings(); err == nil || !strings.Contains(err.Error(), "management_port") {
t.Fatalf("management port conflict returned %v", err)
}
}
func TestTaggedRuleLineNumbersReturnsMatchingRulesDescending(t *testing.T) {
output := []byte(`Chain PREROUTING (policy ACCEPT)
num target prot opt source destination
2 DNAT tcp -- 0.0.0.0/0 0.0.0.0/0 tcp dpt:30080 /* clicd-c12-any-30080 */
7 DNAT tcp -- 0.0.0.0/0 0.0.0.0/0 tcp dpt:30081 /* clicd-c13-any-30081 */
11 DNAT tcp -- 0.0.0.0/0 0.0.0.0/0 tcp dpt:30082 /* clicd-c12-any-30082 */
`)
got := taggedRuleLineNumbers(output, "clicd-c12-")
want := []int{11, 2}
if !reflect.DeepEqual(got, want) {
t.Fatalf("taggedRuleLineNumbers() = %v, want %v", got, want)
}
}
func TestPortMappingConntrackDeleteArgs(t *testing.T) {
got := portMappingConntrackDeleteArgs(config.PortMapping{
HostIP: "203.0.113.10",
HostPort: 32022,
Protocol: "TCP",
})
want := []string{"-D", "-p", "tcp", "--dport", "32022", "--dst", "203.0.113.10"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("portMappingConntrackDeleteArgs() = %v, want %v", got, want)
}
if got := portMappingConntrackDeleteArgs(config.PortMapping{HostPort: 32022, Protocol: "icmp"}); got != nil {
t.Fatalf("unsupported protocol returned args: %v", got)
}
}
func TestUpdateSSHPortMappingKeepsIdentityAndSynchronizesSSHPort(t *testing.T) {
previous := config.AppConfig
t.Cleanup(func() { config.AppConfig = previous })
config.AppConfig = &config.ClicdConfig{
NATPortStart: 30000,
NATPortEnd: 65535,
Containers: []config.Container{{
ID: 12,
Name: "ct-test",
Status: "stopped",
SSHPort: 30022,
PortMappings: []config.PortMapping{{
HostPort: 30022,
ContainerPort: 22,
Protocol: "tcp",
Description: "SSH",
}},
}},
}
manager := NewManager()
mappings, err := manager.UpdatePortMapping(12, 0, config.PortMapping{
HostPort: 31022,
ContainerPort: 22,
Protocol: "tcp",
Description: "renamed",
})
if err != nil {
t.Fatal(err)
}
if len(mappings) != 1 || mappings[0].Description != "SSH" {
t.Fatalf("updated mappings = %+v", mappings)
}
container := config.FindContainer(12)
if container == nil || container.SSHPort != 31022 {
t.Fatalf("container after SSH update = %+v", container)
}
if _, err := manager.DeletePortMapping(12, 0); err == nil {
t.Fatal("updated SSH mapping became deletable")
}
}
func TestReserveCreateNATPortsProtectsConcurrentTasks(t *testing.T) {
previous := config.AppConfig
t.Cleanup(func() { config.AppConfig = previous })
config.AppConfig = &config.ClicdConfig{
NATPortStart: 20000,
NATPortEnd: 65535,
NextSSHPort: 22000,
}
createNATReservationMu.Lock()
createNATReservations = map[uint64][]config.PortMapping{}
queuedCreateNATReservations = map[string][]config.PortMapping{}
createNATReservationMu.Unlock()
t.Cleanup(func() {
createNATReservationMu.Lock()
createNATReservations = map[uint64][]config.PortMapping{}
queuedCreateNATReservations = map[string][]config.PortMapping{}
createNATReservationMu.Unlock()
})
cfg := ContainerConfig{NATPortMappings: []config.PortMapping{{
HostPort: 22000,
ContainerPort: 80,
Protocol: "tcp",
}}}
if err := cfg.NormalizeCreateNATMappings(); err != nil {
t.Fatal(err)
}
managementPort, release, err := ReserveCreateNATPorts(cfg)
if err != nil {
t.Fatal(err)
}
if managementPort == 22000 {
t.Fatal("management port collided with the requested custom host port")
}
if _, _, err := ReserveCreateNATPorts(cfg); err == nil {
t.Fatal("concurrent task reserved an already reserved custom host port")
}
release()
if _, releaseAgain, err := ReserveCreateNATPorts(cfg); err != nil {
t.Fatalf("released custom host port remained reserved: %v", err)
} else {
releaseAgain()
}
explicit := ContainerConfig{ManagementPort: 30022}
if err := explicit.NormalizeCreateNATMappings(); err != nil {
t.Fatal(err)
}
if port, releaseExplicit, err := ReserveCreateNATPorts(explicit); err != nil {
t.Fatal(err)
} else {
defer releaseExplicit()
if port != explicit.ManagementPort {
t.Fatalf("reserved management port = %d, want %d", port, explicit.ManagementPort)
}
}
}
func TestReserveBatchCreateNATPortsPlansAllAutomaticPorts(t *testing.T) {
previous := config.AppConfig
t.Cleanup(func() { config.AppConfig = previous })
config.AppConfig = &config.ClicdConfig{
NATPortStart: 30000,
NATPortEnd: 30010,
NextSSHPort: 30001,
}
createNATReservationMu.Lock()
createNATReservations = map[uint64][]config.PortMapping{}
queuedCreateNATReservations = map[string][]config.PortMapping{}
createNATReservationMu.Unlock()
t.Cleanup(func() {
createNATReservationMu.Lock()
createNATReservations = map[uint64][]config.PortMapping{}
queuedCreateNATReservations = map[string][]config.PortMapping{}
createNATReservationMu.Unlock()
})
configs := []ContainerConfig{
{Name: "batch-1", PortMappingCount: 2},
{Name: "batch-2", PortMappingCount: 2},
}
for i := range configs {
if err := configs[i].NormalizeCreateNATMappings(); err != nil {
t.Fatal(err)
}
}
planned, err := ReserveBatchCreateNATPorts(configs)
if err != nil {
t.Fatal(err)
}
used := map[int]string{}
for _, cfg := range planned {
if cfg.ManagementPort == 0 {
t.Fatalf("%s has no planned management port", cfg.Name)
}
if len(cfg.NATPortMappings) != 1 {
t.Fatalf("%s automatic mappings = %d, want 1", cfg.Name, len(cfg.NATPortMappings))
}
for _, port := range []int{cfg.ManagementPort, cfg.NATPortMappings[0].HostPort} {
if owner := used[port]; owner != "" {
t.Fatalf("planned port %d is shared by %s and %s", port, owner, cfg.Name)
}
used[port] = cfg.Name
}
}
for _, cfg := range planned {
port, release, err := ReserveCreateNATPorts(cfg)
if err != nil {
t.Fatalf("%s could not claim its queued reservation: %v", cfg.Name, err)
}
if port != cfg.ManagementPort {
t.Fatalf("%s claimed management port %d, want %d", cfg.Name, port, cfg.ManagementPort)
}
release()
}
if len(queuedCreateNATReservations) != 0 {
t.Fatalf("queued reservations remain after claim: %v", queuedCreateNATReservations)
}
}
func TestReserveBatchCreateNATPortsRejectsWholeConflictingBatch(t *testing.T) {
previous := config.AppConfig
t.Cleanup(func() { config.AppConfig = previous })
config.AppConfig = &config.ClicdConfig{
NATPortStart: 30000,
NATPortEnd: 30010,
NextSSHPort: 30001,
}
createNATReservationMu.Lock()
createNATReservations = map[uint64][]config.PortMapping{}
queuedCreateNATReservations = map[string][]config.PortMapping{}
createNATReservationMu.Unlock()
t.Cleanup(func() {
createNATReservationMu.Lock()
createNATReservations = map[uint64][]config.PortMapping{}
queuedCreateNATReservations = map[string][]config.PortMapping{}
createNATReservationMu.Unlock()
})
configs := []ContainerConfig{
{Name: "batch-1", NATPortMappings: []config.PortMapping{{HostPort: 30005, ContainerPort: 80, Protocol: "tcp"}}},
{Name: "batch-2", NATPortMappings: []config.PortMapping{{HostPort: 30005, ContainerPort: 8080, Protocol: "tcp"}}},
}
for i := range configs {
if err := configs[i].NormalizeCreateNATMappings(); err != nil {
t.Fatal(err)
}
}
if _, err := ReserveBatchCreateNATPorts(configs); err == nil {
t.Fatal("conflicting batch was accepted")
}
if len(queuedCreateNATReservations) != 0 {
t.Fatalf("conflicting batch left partial reservations: %v", queuedCreateNATReservations)
}
}
func TestRootfsCommandRejectsUnmanagedCommand(t *testing.T) {
base := t.TempDir()
rootfs := filepath.Join(base, "ct-1", "rootfs")
@@ -88,3 +419,96 @@ func TestSafeRootfsPathRejectsSiblingPrefix(t *testing.T) {
t.Fatalf("safeRootfsPath returned %v, want unsafe rootfs path error", err)
}
}
func TestIsLXCVDenylistSeccompProfile(t *testing.T) {
tests := []string{`
# base profile
2
denylist
[all]
open_by_handle_at errno 1
`, `
2
blacklist allow
[all]
open_by_handle_at errno 1
`}
for _, profile := range tests {
if !isLXCVDenylistSeccompProfile(profile) {
t.Fatalf("expected v2 denylist profile for\n%s", profile)
}
}
if isLXCVDenylistSeccompProfile("1\nallowlist\n1\n") {
t.Fatal("did not expect v1 allowlist profile")
}
}
func TestManagedPrlimitLinesDoNotSetNproc(t *testing.T) {
for _, line := range managedPrlimitLines() {
if strings.HasPrefix(strings.TrimSpace(line), "lxc.prlimit.nproc") {
t.Fatalf("managed prlimit lines must not set nproc: %q", line)
}
}
}
func TestRootfsHasSSHD(t *testing.T) {
rootfs := t.TempDir()
if rootfsHasSSHD(rootfs) {
t.Fatal("empty rootfs unexpectedly reports sshd")
}
sshd := filepath.Join(rootfs, "usr", "sbin", "sshd")
if err := os.MkdirAll(filepath.Dir(sshd), 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(sshd, []byte("#!/bin/sh\n"), 0755); err != nil {
t.Fatal(err)
}
if !rootfsHasSSHD(rootfs) {
t.Fatal("executable sshd was not detected")
}
}
func TestSameFilesystemPathResolvesContainerStorageSymlink(t *testing.T) {
base := t.TempDir()
storageContainer := filepath.Join(base, "storage", "ct-1")
rootfs := filepath.Join(storageContainer, "rootfs")
if err := os.MkdirAll(rootfs, 0755); err != nil {
t.Fatal(err)
}
lxcPath := filepath.Join(base, "lxc")
if err := os.MkdirAll(lxcPath, 0755); err != nil {
t.Fatal(err)
}
containerLink := filepath.Join(lxcPath, "ct-1")
if err := os.Symlink(storageContainer, containerLink); err != nil {
t.Skipf("directory symlinks are unavailable: %v", err)
}
linkedRootfs := filepath.Join(containerLink, "rootfs")
if !sameFilesystemPath(rootfs, linkedRootfs) {
t.Fatalf("sameFilesystemPath(%q, %q) = false, want true", rootfs, linkedRootfs)
}
}
func TestAppendMissingSeccompRulesAddsFutexMitigationOnce(t *testing.T) {
base := "2\ndenylist\n[all]\nopen_by_handle_at errno 1\n"
once := appendMissingSeccompRules(base, cve202643499FutexSeccompRules)
twice := appendMissingSeccompRules(once, cve202643499FutexSeccompRules)
for _, want := range []string{
"futex errno 1 [1,0x6,SCMP_CMP_MASKED_EQ,0x7f]",
"futex errno 1 [1,0xb,SCMP_CMP_MASKED_EQ,0x7f]",
"futex errno 1 [1,0xc,SCMP_CMP_MASKED_EQ,0x7f]",
"futex errno 1 [1,0xd,SCMP_CMP_MASKED_EQ,0x7f]",
} {
if !strings.Contains(once, want) {
t.Fatalf("missing seccomp rule %q in\n%s", want, once)
}
if strings.Count(twice, want) != 1 {
t.Fatalf("rule %q duplicated in\n%s", want, twice)
}
}
}
File diff suppressed because it is too large Load Diff
+44 -6
View File
@@ -16,7 +16,7 @@ import (
var snapshotMu sync.Mutex
func (m *Manager) CreateSnapshot(id int, createdBy string, scheduled bool, rotateLimit int) (config.Snapshot, error) {
func (m *Manager) CreateSnapshot(id int, createdBy string, scheduled bool, rotateLimit int, storagePoolID ...string) (config.Snapshot, error) {
snapshotMu.Lock()
defer snapshotMu.Unlock()
@@ -42,12 +42,21 @@ func (m *Manager) CreateSnapshot(id int, createdBy string, scheduled bool, rotat
if _, err := os.Stat(containerDir); err != nil {
return config.Snapshot{}, fmt.Errorf("container storage not found: %v", err)
}
pool, err := config.SelectStoragePoolForContent(
config.StorageContentSnapshots,
firstString(storagePoolID),
dirSizeBytes(containerDir),
)
if err != nil {
return config.Snapshot{}, err
}
now := time.Now()
snapshotID := fmt.Sprintf("snap-%d-%s", id, now.Format("20060102150405-000000000"))
// Use container ID instead of lxcName to avoid collision when containers are recreated
snapshotDir := filepath.Join(snapshotBaseDir(), strconv.Itoa(id), snapshotID)
if err := safePathUnder(snapshotDir, snapshotBaseDir()); err != nil {
baseDir := filepath.Join(pool.Path, "snapshots")
snapshotDir := filepath.Join(baseDir, strconv.Itoa(id), snapshotID)
if err := safePathUnder(snapshotDir, baseDir); err != nil {
return config.Snapshot{}, err
}
if err := os.MkdirAll(snapshotDir, 0700); err != nil {
@@ -100,7 +109,7 @@ func (m *Manager) DeleteSnapshot(id string) error {
func (m *Manager) deleteSnapshotLocked(snapshot config.Snapshot) error {
if snapshot.Path != "" {
if err := safePathUnder(snapshot.Path, snapshotBaseDir()); err != nil {
if err := safeSnapshotPath(snapshot.Path); err != nil {
return err
}
if err := os.RemoveAll(snapshot.Path); err != nil {
@@ -122,7 +131,7 @@ func (m *Manager) RestoreSnapshot(id string) error {
if snapshot.Path == "" {
return fmt.Errorf("snapshot path is empty")
}
if err := safePathUnder(snapshot.Path, snapshotBaseDir()); err != nil {
if err := safeSnapshotPath(snapshot.Path); err != nil {
return err
}
if _, err := os.Stat(snapshot.Path); err != nil {
@@ -295,7 +304,33 @@ func (m *Manager) prepareContainerForColdCopy(id int, lxcName string, containerD
}
func snapshotBaseDir() string {
return filepath.Join(config.AppConfig.DataDir, "snapshots")
return snapshotBaseDirForPool("")
}
func snapshotBaseDirForPool(poolID string) string {
if pool, err := config.SelectStoragePoolForContent(config.StorageContentSnapshots, poolID, 0); err == nil {
return filepath.Join(pool.Path, "snapshots")
}
return ""
}
func safeSnapshotPath(path string) error {
if err := safePathUnder(path, filepath.Join(config.AppConfig.DataDir, "snapshots")); err == nil {
return nil
}
for _, pool := range config.StoragePoolsForContent(config.StorageContentSnapshots) {
if err := safePathUnder(path, filepath.Join(pool.Path, "snapshots")); err == nil {
return nil
}
}
return fmt.Errorf("unsafe snapshot path: %s", path)
}
func firstString(values []string) string {
if len(values) == 0 {
return ""
}
return strings.TrimSpace(values[0])
}
func copyTree(src string, dst string) error {
@@ -313,6 +348,9 @@ func copyTree(src string, dst string) error {
}
func dirSizeBytes(path string) int64 {
if resolved, err := filepath.EvalSymlinks(path); err == nil {
path = resolved
}
out, err := exec.Command("du", "-s", "-B1", path).Output()
if err != nil {
return 0
+221
View File
@@ -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
}
}
+51 -10
View File
@@ -1,5 +1,11 @@
package lxc
import (
"runtime"
"clicd/internal/config"
)
// Template represents an LXC image template
type Template struct {
ID string `json:"id"`
@@ -9,57 +15,92 @@ type Template struct {
Arch string `json:"arch"`
Variant string `json:"variant"`
Description string `json:"description"`
URL string `json:"url,omitempty"`
SHA256 string `json:"sha256,omitempty"`
Custom bool `json:"custom,omitempty"`
}
// GetTemplates returns available LXC image templates (only verified working ones)
func GetTemplates() []Template {
return []Template{
arch := defaultTemplateArch()
templates := []Template{
{
ID: "ubuntu-noble", Name: "Ubuntu 24.04",
Distro: "ubuntu", Release: "noble", Arch: "amd64",
Distro: "ubuntu", Release: "noble", Arch: arch,
Description: "Ubuntu 24.04 LTS",
},
{
ID: "ubuntu-jammy", Name: "Ubuntu 22.04",
Distro: "ubuntu", Release: "jammy", Arch: "amd64",
Distro: "ubuntu", Release: "jammy", Arch: arch,
Description: "Ubuntu 22.04 LTS",
},
{
ID: "debian-trixie", Name: "Debian 13",
Distro: "debian", Release: "trixie", Arch: arch,
Description: "Debian 13 (Trixie)",
},
{
ID: "debian-bookworm", Name: "Debian 12",
Distro: "debian", Release: "bookworm", Arch: "amd64",
Distro: "debian", Release: "bookworm", Arch: arch,
Description: "Debian 12 (Bookworm)",
},
{
ID: "debian-bullseye", Name: "Debian 11",
Distro: "debian", Release: "bullseye", Arch: "amd64",
Distro: "debian", Release: "bullseye", Arch: arch,
Description: "Debian 11 (Bullseye)",
},
{
ID: "alpine-3.21", Name: "Alpine 3.21",
Distro: "alpine", Release: "3.21", Arch: "amd64",
Distro: "alpine", Release: "3.21", Arch: arch,
Description: "Alpine Linux 3.21",
},
{
ID: "centos-9-stream", Name: "CentOS 9 Stream",
Distro: "centos", Release: "9-Stream", Arch: "amd64",
Distro: "centos", Release: "9-Stream", Arch: arch,
Description: "CentOS 9 Stream",
},
{
ID: "archlinux-current", Name: "Arch Linux",
Distro: "archlinux", Release: "current", Arch: "amd64", Variant: "cloud",
Distro: "archlinux", Release: "current", Arch: arch,
Description: "Arch Linux (Rolling)",
},
{
ID: "fedora-44", Name: "Fedora 44",
Distro: "fedora", Release: "44", Arch: "amd64", Variant: "cloud",
Distro: "fedora", Release: "44", Arch: arch,
Description: "Fedora 44",
},
{
ID: "rockylinux-10", Name: "Rocky Linux 10",
Distro: "rockylinux", Release: "10", Arch: "amd64", Variant: "cloud",
Distro: "rockylinux", Release: "10", Arch: arch,
Description: "Rocky Linux 10",
},
}
for _, custom := range config.ListCustomLXCImages() {
if custom.Arch != arch {
continue
}
templates = append(templates, Template{
ID: custom.ID,
Name: custom.Name,
Distro: custom.Distro,
Release: custom.Release,
Arch: custom.Arch,
Description: custom.Description,
URL: custom.URL,
SHA256: custom.SHA256,
Custom: true,
})
}
return templates
}
func defaultTemplateArch() string {
switch runtime.GOARCH {
case "arm64":
return "arm64"
default:
return "amd64"
}
}
// FindTemplate finds a template by ID
+214
View File
@@ -0,0 +1,214 @@
package safehttp
import (
"context"
"fmt"
"net"
"net/http"
"net/netip"
"net/url"
"strconv"
"strings"
"time"
)
const maxRedirects = 10
var blockedPrefixes = []netip.Prefix{
netip.MustParsePrefix("0.0.0.0/8"),
netip.MustParsePrefix("10.0.0.0/8"),
netip.MustParsePrefix("100.64.0.0/10"),
netip.MustParsePrefix("127.0.0.0/8"),
netip.MustParsePrefix("169.254.0.0/16"),
netip.MustParsePrefix("172.16.0.0/12"),
netip.MustParsePrefix("192.0.0.0/24"),
netip.MustParsePrefix("192.0.2.0/24"),
netip.MustParsePrefix("192.88.99.0/24"),
netip.MustParsePrefix("192.168.0.0/16"),
netip.MustParsePrefix("198.18.0.0/15"),
netip.MustParsePrefix("198.51.100.0/24"),
netip.MustParsePrefix("203.0.113.0/24"),
netip.MustParsePrefix("224.0.0.0/4"),
netip.MustParsePrefix("240.0.0.0/4"),
netip.MustParsePrefix("::/128"),
netip.MustParsePrefix("::1/128"),
netip.MustParsePrefix("64:ff9b::/96"),
netip.MustParsePrefix("64:ff9b:1::/48"),
netip.MustParsePrefix("100::/64"),
netip.MustParsePrefix("2001::/32"),
netip.MustParsePrefix("2001:2::/48"),
netip.MustParsePrefix("2001:db8::/32"),
netip.MustParsePrefix("2001:20::/28"),
netip.MustParsePrefix("2002::/16"),
netip.MustParsePrefix("fc00::/7"),
netip.MustParsePrefix("fec0::/10"),
netip.MustParsePrefix("fe80::/10"),
netip.MustParsePrefix("ff00::/8"),
}
// ValidateURL performs the URL checks that do not require DNS. Host addresses
// are checked again after resolution and immediately before every connection.
func ValidateURL(rawURL string) (*url.URL, error) {
if len(rawURL) == 0 || len(rawURL) > 4096 {
return nil, fmt.Errorf("download URL must be between 1 and 4096 characters")
}
parsed, err := url.ParseRequestURI(rawURL)
if err != nil {
return nil, fmt.Errorf("invalid download URL: %v", err)
}
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return nil, fmt.Errorf("download URL must use HTTP or HTTPS")
}
if parsed.Host == "" || parsed.Hostname() == "" {
return nil, fmt.Errorf("download URL must include a host")
}
if parsed.User != nil {
return nil, fmt.Errorf("download URL must not include credentials")
}
if parsed.Fragment != "" {
return nil, fmt.Errorf("download URL must not include a fragment")
}
if port := parsed.Port(); port != "" {
value, err := strconv.Atoi(port)
if err != nil || value < 1 || value > 65535 {
return nil, fmt.Errorf("download URL contains an invalid port")
}
}
if addr, err := netip.ParseAddr(parsed.Hostname()); err == nil && !isPublicAddress(addr) {
return nil, fmt.Errorf("download URL resolves to a non-public address")
}
return parsed, nil
}
// Get retrieves a resource only when every resolved destination is public.
func Get(ctx context.Context, rawURL, userAgent string, timeout time.Duration) (*http.Response, error) {
parsed, err := ValidateURL(rawURL)
if err != nil {
return nil, err
}
if err := validateHost(ctx, net.DefaultResolver, parsed.Hostname()); err != nil {
return nil, err
}
request, err := http.NewRequestWithContext(ctx, http.MethodGet, parsed.String(), nil)
if err != nil {
return nil, err
}
request.Header.Set("User-Agent", userAgent)
client := &http.Client{
Timeout: timeout,
Transport: publicTransport(net.DefaultResolver),
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= maxRedirects {
return fmt.Errorf("too many redirects")
}
redirect, err := ValidateURL(req.URL.String())
if err != nil {
return err
}
if err := validateHost(req.Context(), net.DefaultResolver, redirect.Hostname()); err != nil {
return err
}
if len(via) > 0 {
req.Header.Set("User-Agent", via[0].Header.Get("User-Agent"))
}
return nil
},
}
// All URL components, redirects, DNS answers and dial destinations are
// constrained above and in publicTransport.
// lgtm[go/request-forgery]
return client.Do(request)
}
func publicTransport(resolver *net.Resolver) *http.Transport {
dialer := &net.Dialer{
Timeout: 30 * time.Second,
KeepAlive: 30 * time.Second,
}
return &http.Transport{
Proxy: nil,
DialContext: func(ctx context.Context, network, address string) (net.Conn, error) {
host, port, err := net.SplitHostPort(address)
if err != nil {
return nil, fmt.Errorf("invalid download destination: %v", err)
}
addresses, err := resolvePublicHost(ctx, resolver, host)
if err != nil {
return nil, err
}
var lastErr error
for _, addr := range addresses {
conn, err := dialer.DialContext(ctx, network, net.JoinHostPort(addr.String(), port))
if err == nil {
return conn, nil
}
lastErr = err
}
if lastErr == nil {
lastErr = fmt.Errorf("host has no usable public addresses")
}
return nil, lastErr
},
ForceAttemptHTTP2: true,
TLSHandshakeTimeout: 30 * time.Second,
IdleConnTimeout: 90 * time.Second,
}
}
func validateHost(ctx context.Context, resolver *net.Resolver, host string) error {
_, err := resolvePublicHost(ctx, resolver, host)
return err
}
func resolvePublicHost(ctx context.Context, resolver *net.Resolver, host string) ([]netip.Addr, error) {
host = strings.TrimSpace(strings.TrimSuffix(host, "."))
if host == "" {
return nil, fmt.Errorf("download URL host is empty")
}
if strings.EqualFold(host, "localhost") || strings.HasSuffix(strings.ToLower(host), ".localhost") {
return nil, fmt.Errorf("download URL host is not public")
}
if addr, err := netip.ParseAddr(host); err == nil {
addr = addr.Unmap()
if !isPublicAddress(addr) {
return nil, fmt.Errorf("download URL resolves to a non-public address")
}
return []netip.Addr{addr}, nil
}
addresses, err := resolver.LookupNetIP(ctx, "ip", host)
if err != nil {
return nil, fmt.Errorf("failed to resolve download host: %v", err)
}
if len(addresses) == 0 {
return nil, fmt.Errorf("download host has no IP addresses")
}
result := make([]netip.Addr, 0, len(addresses))
for _, address := range addresses {
address = address.Unmap()
if !isPublicAddress(address) {
return nil, fmt.Errorf("download host resolves to a non-public address")
}
result = append(result, address)
}
return result, nil
}
func isPublicAddress(address netip.Addr) bool {
if !address.IsValid() || address.Zone() != "" || !address.IsGlobalUnicast() || address.IsPrivate() ||
address.IsLoopback() || address.IsLinkLocalUnicast() || address.IsLinkLocalMulticast() ||
address.IsMulticast() || address.IsUnspecified() {
return false
}
address = address.Unmap()
for _, prefix := range blockedPrefixes {
if prefix.Contains(address) {
return false
}
}
return true
}
@@ -0,0 +1,74 @@
package safehttp
import (
"context"
"net/netip"
"testing"
"time"
)
func TestValidateURLRejectsUnsafeDestinations(t *testing.T) {
t.Parallel()
for _, rawURL := range []string{
"file:///etc/passwd",
"http://user:pass@example.com/image",
"http://127.0.0.1/image",
"http://[::1]/image",
"http://169.254.169.254/latest/meta-data",
"http://10.0.0.1/image",
"http://192.168.1.10/image",
"http://100.64.0.1/image",
"http://example.com:99999/image",
} {
if _, err := ValidateURL(rawURL); err == nil {
t.Fatalf("ValidateURL(%q) succeeded, want rejection", rawURL)
}
}
}
func TestValidateURLAcceptsPublicHTTPURL(t *testing.T) {
t.Parallel()
parsed, err := ValidateURL("https://example.com/images/rootfs.tar.xz?variant=default")
if err != nil {
t.Fatalf("ValidateURL returned error: %v", err)
}
if parsed.Hostname() != "example.com" {
t.Fatalf("hostname = %q, want example.com", parsed.Hostname())
}
}
func TestIsPublicAddress(t *testing.T) {
t.Parallel()
tests := map[string]bool{
"8.8.8.8": true,
"1.1.1.1": true,
"2606:4700:4700::1111": true,
"127.0.0.1": false,
"10.0.0.1": false,
"100.64.0.1": false,
"169.254.169.254": false,
"192.0.2.1": false,
"198.18.0.1": false,
"::1": false,
"64:ff9b::127.0.0.1": false,
"2002:7f00:1::1": false,
"fc00::1": false,
"fec0::1": false,
"fe80::1": false,
"2001:db8::1": false,
}
for raw, expected := range tests {
if actual := isPublicAddress(netip.MustParseAddr(raw)); actual != expected {
t.Errorf("isPublicAddress(%s) = %v, want %v", raw, actual, expected)
}
}
}
func TestGetRejectsLoopbackBeforeRequest(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
if _, err := Get(ctx, "http://127.0.0.1:1/image", "test", time.Second); err == nil {
t.Fatal("Get accepted a loopback destination")
}
}
+39
View File
@@ -0,0 +1,39 @@
package server
import (
"encoding/json"
"net/http"
"strings"
"clicd/internal/config"
)
func panelAccessMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
decision := config.EvaluatePanelAccess(
config.AppConfig.PanelAccessPolicy,
r.RemoteAddr,
config.ForwardedClientHeaders{
ForwardedFor: r.Header.Get("X-Forwarded-For"),
RealIP: r.Header.Get("X-Real-IP"),
CFConnectingIP: r.Header.Get("CF-Connecting-IP"),
},
)
if decision.Allowed {
next.ServeHTTP(w, r)
return
}
w.Header().Set("Cache-Control", "no-store")
if strings.HasPrefix(r.URL.Path, "/api/") {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusForbidden)
_ = json.NewEncoder(w).Encode(map[string]any{
"success": false,
"message": "Access denied by panel source policy",
})
return
}
http.Error(w, "Access denied by panel source policy", http.StatusForbidden)
})
}
@@ -0,0 +1,46 @@
package server
import (
"net/http"
"net/http/httptest"
"testing"
"clicd/internal/config"
)
func TestPanelAccessMiddleware(t *testing.T) {
previous := config.AppConfig
config.AppConfig = &config.ClicdConfig{
PanelAccessPolicy: config.PanelAccessPolicy{
Enabled: true,
AllowedSources: []string{"192.0.2.0/24"},
TrustedProxies: []string{"10.0.0.1"},
},
}
t.Cleanup(func() {
config.AppConfig = previous
})
handler := panelAccessMiddleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
allowed := httptest.NewRequest(http.MethodGet, "/api/version", nil)
allowed.RemoteAddr = "192.0.2.8:50000"
allowedRecorder := httptest.NewRecorder()
handler.ServeHTTP(allowedRecorder, allowed)
if allowedRecorder.Code != http.StatusNoContent {
t.Fatalf("allowed status = %d", allowedRecorder.Code)
}
denied := httptest.NewRequest(http.MethodGet, "/api/version", nil)
denied.RemoteAddr = "198.51.100.8:50000"
deniedRecorder := httptest.NewRecorder()
handler.ServeHTTP(deniedRecorder, denied)
if deniedRecorder.Code != http.StatusForbidden {
t.Fatalf("denied status = %d", deniedRecorder.Code)
}
if got := deniedRecorder.Header().Get("Content-Type"); got != "application/json" {
t.Fatalf("denied content type = %q", got)
}
}
+19 -33
View File
@@ -4,9 +4,7 @@ import (
"crypto/tls"
"fmt"
"log"
"net"
"net/http"
"net/url"
"strings"
"clicd/internal/api"
@@ -19,7 +17,7 @@ var webFS http.FileSystem
// corsMiddleware adds CORS headers
func corsMiddleware(next http.HandlerFunc) http.HandlerFunc {
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("Vary", "Origin")
w.Header().Set("Access-Control-Allow-Credentials", "true")
@@ -28,7 +26,7 @@ func corsMiddleware(next http.HandlerFunc) http.HandlerFunc {
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-API-Key")
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)
return
}
@@ -40,34 +38,6 @@ 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
func setupRoutes(mux *http.ServeMux) {
// API routes
@@ -78,11 +48,14 @@ func setupRoutes(mux *http.ServeMux) {
mux.HandleFunc("/api/change-username", corsMiddleware(api.AdminMiddleware(api.HandleAdminUsernameChange)))
mux.HandleFunc("/api/login-logs", corsMiddleware(api.AdminMiddleware(api.HandleLoginLogs)))
mux.HandleFunc("/api/ssl", corsMiddleware(api.AdminMiddleware(api.HandleSSLSettings)))
mux.HandleFunc("/api/webssh-origins", corsMiddleware(api.AdminMiddleware(api.HandleWebSSHOriginSettings)))
mux.HandleFunc("/api/access-policy", corsMiddleware(api.AdminMiddleware(api.HandlePanelAccessPolicy)))
mux.HandleFunc("/api/containers", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleContainers))))
mux.HandleFunc("/api/containers/list", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleContainerListAlias))))
mux.HandleFunc("/api/containers/", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleSingleContainer))))
mux.HandleFunc("/api/templates", corsMiddleware(api.AuthMiddleware(api.HandleTemplates)))
mux.HandleFunc("/api/images", corsMiddleware(api.AdminMiddleware(api.HandleImages)))
mux.HandleFunc("/api/images/custom", corsMiddleware(api.AdminMiddleware(api.HandleCustomKVMImages)))
mux.HandleFunc("/api/images/download", corsMiddleware(api.AdminMiddleware(api.HandleImageDownload)))
mux.HandleFunc("/api/images/cancel", corsMiddleware(api.AdminMiddleware(api.HandleImageCancel)))
mux.HandleFunc("/api/images/delete", corsMiddleware(api.AdminMiddleware(api.HandleImageDelete)))
@@ -90,12 +63,16 @@ func setupRoutes(mux *http.ServeMux) {
mux.HandleFunc("/api/images/enabled", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleEnabledImages))))
mux.HandleFunc("/api/dashboard", corsMiddleware(api.AdminMiddleware(api.HandleDashboard)))
mux.HandleFunc("/api/host-info", corsMiddleware(api.AdminMiddleware(api.HandleHostInfo)))
mux.HandleFunc("/api/host-history", corsMiddleware(api.AdminMiddleware(api.HandleHostHistory)))
mux.HandleFunc("/api/host-report", corsMiddleware(api.AdminMiddleware(api.HandleHostReport)))
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/storage", corsMiddleware(api.AdminMiddleware(api.HandleStorage)))
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.AdminMiddleware(api.HandleTaskDelete))))
mux.HandleFunc("/api/task-queue/settings", corsMiddleware(api.AdminMiddleware(api.HandleTaskQueueSettings)))
mux.HandleFunc("/api/batch-create", corsMiddleware(api.AdminMiddleware(api.HandleBatchCreate)))
mux.HandleFunc("/api/batch-action", corsMiddleware(api.AdminMiddleware(api.HandleBatchAction)))
mux.HandleFunc("/api/sub-user/create", corsMiddleware(api.AdminMiddleware(api.HandleSubUserCreate)))
@@ -126,18 +103,23 @@ func setupRoutes(mux *http.ServeMux) {
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/custom", corsMiddleware(api.AuthMiddleware(api.HandleCustomKVMImages)))
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-history", corsMiddleware(api.AuthMiddleware(api.HandleHostHistory)))
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/storage", corsMiddleware(api.AdminMiddleware(api.HandleStorage)))
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/task-queue/settings", corsMiddleware(api.AdminMiddleware(api.HandleTaskQueueSettings)))
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)))
@@ -146,6 +128,8 @@ func setupRoutes(mux *http.ServeMux) {
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/access-policy", corsMiddleware(api.AdminMiddleware(api.HandlePanelAccessPolicy)))
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))))
@@ -200,6 +184,8 @@ func setupRoutes(mux *http.ServeMux) {
func Run() error {
// Use embedded frontend files
webFS = GetEmbeddedFS()
api.StartHostMetricSampler()
api.StartContainerMetricSampler()
mux := http.NewServeMux()
setupRoutes(mux)
@@ -210,7 +196,7 @@ func Run() error {
server := &http.Server{
Addr: addr,
Handler: mux,
Handler: panelAccessMiddleware(mux),
}
if sslEnabled() {
+1 -1
View File
@@ -1,7 +1,7 @@
package version
var (
Version = "1.1.9"
Version = "1.1.28"
Repo = "MengMengCode/CLICD"
)
+31
View File
@@ -4,7 +4,10 @@ import (
"fmt"
"os"
"os/exec"
"os/signal"
"strings"
"sync"
"syscall"
"clicd/internal/api"
"clicd/internal/cli"
@@ -16,12 +19,15 @@ import (
"golang.org/x/term"
)
var shutdownCaptureOnce sync.Once
func main() {
isTerminal := term.IsTerminal(int(os.Stdin.Fd()))
isServerMode := false
isCliMode := false
noWebAutostart := false
isAccessPolicyCommand := len(os.Args) > 1 && os.Args[1] == "access-policy"
for _, arg := range os.Args[1:] {
if arg == "server" || arg == "-s" || arg == "--server" {
isServerMode = true
@@ -43,8 +49,19 @@ func main() {
}
_ = cfg
if isAccessPolicyCommand {
if err := cli.RunAccessPolicyCommand(os.Args[2:]); err != nil {
fmt.Fprintf(os.Stderr, "Access policy error: %v\n", err)
os.Exit(1)
}
return
}
if isServerMode || (!isTerminal && !isCliMode) {
installShutdownStateCapture()
// Restore persisted state
api.ConfigureTaskQueue(cfg.TaskConcurrency)
api.RestoreTasks()
api.RestoreLoginLogs()
@@ -55,6 +72,7 @@ func main() {
// Ensure iptables FORWARD rules allow managed bridge traffic.
lxc.EnsureForwardRules("lxcbr0")
lxc.EnsureForwardRules("virbr0")
lxc.EnsureAllAssignedPublicIPv4s()
// Start expiry scanners (stops expired/over-traffic workloads every 30s)
manager := lxc.NewManager()
@@ -74,6 +92,8 @@ func main() {
// Clean up stale container configs (LXC dir was deleted but config remains)
config.CleanStaleContainers()
api.StartHostBootRestore()
lxc.EnsureAllRunningPortMappings()
// Pre-warm SSH for containers already running after host boot or service restart.
manager.StartSSHWarmupScanner()
@@ -95,6 +115,17 @@ func main() {
}
}
func installShutdownStateCapture() {
signals := make(chan os.Signal, 1)
signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM)
go func() {
sig := <-signals
fmt.Fprintf(os.Stderr, "Received %s, capturing workload restore state...\n", sig)
shutdownCaptureOnce.Do(api.CaptureRuntimeRestoreState)
os.Exit(0)
}()
}
func isWebPanelSystemdRunning() bool {
cmd := exec.Command("systemctl", "is-active", "clicd")
output, err := cmd.Output()
+41 -3
View File
@@ -6,6 +6,7 @@ set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BUILD_DIR="$SCRIPT_DIR/build"
DIST_DIR="$SCRIPT_DIR/dist"
FRONTEND_DIR="$SCRIPT_DIR/frontend"
BACKEND_DIR="$SCRIPT_DIR/backend"
WEB_DIR="$SCRIPT_DIR/web"
@@ -17,9 +18,11 @@ echo "====================================="
# Clean previous build
rm -rf "$BUILD_DIR"
rm -rf "$DIST_DIR"
rm -rf "$WEB_DIR"
rm -rf "$EMBED_WEB_DIR"
mkdir -p "$BUILD_DIR"
mkdir -p "$DIST_DIR"
mkdir -p "$WEB_DIR"
mkdir -p "$EMBED_WEB_DIR"
touch "$EMBED_WEB_DIR/.gitkeep"
@@ -51,9 +54,26 @@ cd "$BACKEND_DIR"
go mod tidy
go mod download
# Build for Linux amd64
BUILD_VERSION="${CLICD_VERSION:-dev}"
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -ldflags="-s -w -X clicd/internal/version.Version=${BUILD_VERSION}" -o "$BUILD_DIR/clicd" .
TARGET_GOOS="${CLICD_GOOS:-linux}"
TARGET_GOARCH="${CLICD_GOARCH:-amd64}"
case "$TARGET_GOARCH" in
all) TARGET_GOARCH_LIST="amd64 arm64" ;;
amd64|arm64) TARGET_GOARCH_LIST="$TARGET_GOARCH" ;;
*)
echo "Unsupported CLICD_GOARCH: $TARGET_GOARCH (expected amd64, arm64, or all)" >&2
exit 2
;;
esac
for arch in $TARGET_GOARCH_LIST; do
echo "Target: ${TARGET_GOOS}/${arch}"
GOOS="$TARGET_GOOS" GOARCH="$arch" CGO_ENABLED=0 go build -ldflags="-s -w -X clicd/internal/version.Version=${BUILD_VERSION}" -o "$BUILD_DIR/clicd-linux-${arch}" .
done
first_arch="${TARGET_GOARCH_LIST%% *}"
cp "$BUILD_DIR/clicd-linux-${first_arch}" "$BUILD_DIR/clicd"
echo "Go backend built successfully"
@@ -62,7 +82,20 @@ echo ""
echo "[3/3] Packaging..."
cp -r "$WEB_DIR" "$BUILD_DIR/web"
cp "$SCRIPT_DIR/install.sh" "$BUILD_DIR/install.sh" 2>/dev/null || true
chmod +x "$BUILD_DIR/clicd"
chmod +x "$BUILD_DIR"/clicd*
for arch in $TARGET_GOARCH_LIST; do
asset_dir="clicd-linux-${arch}"
package_root="$BUILD_DIR/package-${arch}"
rm -rf "$package_root"
mkdir -p "$package_root/$asset_dir"
cp "$BUILD_DIR/clicd-linux-${arch}" "$package_root/$asset_dir/clicd"
cp "$BUILD_DIR/install.sh" "$package_root/$asset_dir/install.sh" 2>/dev/null || true
chmod +x "$package_root/$asset_dir/clicd"
[ ! -f "$package_root/$asset_dir/install.sh" ] || chmod +x "$package_root/$asset_dir/install.sh"
tar -C "$package_root" -czf "$DIST_DIR/${asset_dir}.tar.gz" "$asset_dir"
cp "$BUILD_DIR/clicd-linux-${arch}" "$DIST_DIR/${asset_dir}"
done
echo ""
echo "====================================="
@@ -70,6 +103,11 @@ echo " Build Complete!"
echo "====================================="
echo " Output: $BUILD_DIR/clicd"
echo " Web: $BUILD_DIR/web/"
echo " Dist: $DIST_DIR/"
for arch in $TARGET_GOARCH_LIST; do
echo " dist/clicd-linux-${arch}"
echo " dist/clicd-linux-${arch}.tar.gz"
done
echo ""
echo " To deploy:"
echo " 1. Copy build/ directory to server"
+144 -48
View File
@@ -1,5 +1,105 @@
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 的轻量虚拟化管理面板文档',
@@ -10,59 +110,55 @@ export default defineConfig({
head: [
['link', { rel: 'icon', href: '/favicon.svg' }],
],
vite: {
esbuild: {
supported: {
destructuring: true,
},
},
},
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',
},
nav: [
{ text: '指南', link: '/guide/introduction' },
{ text: '功能', link: '/features/dashboard' },
{ text: '运维', link: '/operations/deployment' },
{ text: '开发', link: '/developer/architecture' },
],
sidebar: [
{
text: '开始',
items: [
{ text: '项目介绍', link: '/guide/introduction' },
{ text: '安装', link: '/guide/installation' },
{ text: '升级', link: '/guide/upgrade' },
{ text: '快速上手', link: '/guide/quick-start' },
{ text: '配置说明', link: '/guide/configuration' },
],
},
{
text: '功能',
items: [
{ text: '控制面板', link: '/features/dashboard' },
{ text: '容器管理', link: '/features/containers' },
{ text: '镜像管理', link: '/features/images' },
{ text: '网络与路由', link: '/features/networking' },
{ text: '快照管理', link: '/features/snapshots' },
{ text: '安全告警', link: '/features/security' },
{ text: '子用户', link: '/features/sub-users' },
{ text: 'API 集成', link: '/features/api' },
{ text: '主机报告', link: '/features/host-report' },
],
},
{
text: '运维',
items: [
{ text: '部署建议', link: '/operations/deployment' },
{ text: '故障排查', link: '/operations/troubleshooting' },
{ text: '常见问题', link: '/operations/faq' },
],
},
{
text: '开发',
items: [
{ text: '系统架构', link: '/developer/architecture' },
{ text: '本地构建', link: '/developer/build' },
{ text: '发布流程', link: '/developer/release' },
],
},
],
socialLinks: [
{ icon: 'github', link: 'https://github.com/MengMengCode/CLICD' },
],
+19
View File
@@ -30,6 +30,25 @@ bash build.sh
该脚本用于串联前端构建、静态资源同步和 Go 二进制构建。
默认目标为 Linux amd64。需要构建 ARM64 包时可以指定:
```bash
CLICD_GOARCH=arm64 bash build.sh
```
需要同时构建 amd64 和 arm64 发布包时:
```bash
CLICD_GOARCH=all bash build.sh
```
构建完成后会生成:
- `dist/clicd-linux-amd64`
- `dist/clicd-linux-amd64.tar.gz`
- `dist/clicd-linux-arm64`
- `dist/clicd-linux-arm64.tar.gz`
## 文档站构建
```bash
+3 -1
View File
@@ -12,16 +12,18 @@ CLICD 的安装和升级依赖 GitHub Release 产物。发布时建议使用语
## Release 产物
安装脚本会优先下载 Linux AMD64 产物:
安装脚本会按宿主架构优先下载 Linux AMD64 或 ARM64 产物:
```text
clicd-linux-amd64.tar.gz
clicd-linux-arm64.tar.gz
```
在部分场景中也会尝试下载单独二进制:
```text
clicd-linux-amd64
clicd-linux-arm64
```
## 安装脚本行为
+42
View File
@@ -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.
+61
View File
@@ -0,0 +1,61 @@
# 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.
The default target is Linux amd64. To build an ARM64 package, set:
```bash
CLICD_GOARCH=arm64 bash build.sh
```
To build both amd64 and arm64 release assets at once:
```bash
CLICD_GOARCH=all bash build.sh
```
The build writes:
- `dist/clicd-linux-amd64`
- `dist/clicd-linux-amd64.tar.gz`
- `dist/clicd-linux-arm64`
- `dist/clicd-linux-arm64.tar.gz`
## 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.
+46
View File
@@ -0,0 +1,46 @@
# 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 or ARM64 archive for the host architecture:
```text
clicd-linux-amd64.tar.gz
clicd-linux-arm64.tar.gz
```
In some cases, it may also try the standalone binary:
```text
clicd-linux-amd64
clicd-linux-arm64
```
## 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.
+880
View File
@@ -0,0 +1,880 @@
# 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": "",
"network_down_mbps": 100,
"network_up_mbps": 50,
"io_read_mbps": 120,
"io_write_mbps": 80
}
```
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. |
| `network_down_mbps` | Optional container download/downlink bandwidth limit in Mbps. `0` means unlimited. |
| `network_up_mbps` | Optional container upload/uplink bandwidth limit in Mbps. `0` means unlimited. |
| `io_read_mbps` | Optional disk read limit in MB/s. `0` means unlimited. |
| `io_write_mbps` | Optional disk write limit in MB/s. `0` means unlimited. |
| `network_bw_mbps` | Legacy-compatible field. Sets symmetric downlink/uplink bandwidth; new integrations should prefer the split fields. |
| `io_speed_mbps` | Legacy-compatible field. Sets symmetric read/write I/O limits; new integrations should prefer the split fields. |
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.
## Resource and Traffic Limits
`PUT /api/v1/containers/{id}/resource-limit` supports partial updates. Fields omitted from the request remain unchanged.
```json
{
"vcpu": 2,
"ram_mb": 1024,
"network_down_mbps": 100,
"network_up_mbps": 50,
"io_read_mbps": 120,
"io_write_mbps": 80
}
```
Legacy `network_bw_mbps` and `io_speed_mbps` are still accepted. They mean symmetric downlink/uplink bandwidth and symmetric read/write I/O limits. New integrations should use the split fields to control download/upload and read/write independently.
`PUT /api/v1/containers/{id}/traffic-limit` request body:
```json
{
"traffic_mode": "total",
"monthly_traffic_gb": 1024,
"traffic_in_gb": 0,
"traffic_out_gb": 0
}
```
| Field | Description |
| --- | --- |
| `traffic_mode` | Traffic limit mode. Common values are `total` for a shared total limit and `split` for separate inbound/outbound limits. |
| `monthly_traffic_gb` | Monthly total traffic quota for `total` mode, in GB. `0` means unlimited. |
| `traffic_in_gb` | Monthly inbound quota for `split` mode, in GB. `0` means unlimited. |
| `traffic_out_gb` | Monthly outbound quota for `split` mode, in GB. `0` means unlimited. |
## Container Firewall
Read container firewall settings with `GET /api/v1/containers/{id}/firewall` and update them with `PUT /api/v1/containers/{id}/firewall`. Updates are applied immediately when the container is running.
Update example:
```json
{
"enabled": true,
"default_action": "DROP",
"rules": [
{
"direction": "in",
"protocol": "tcp",
"action": "ACCEPT",
"network": "ipv4",
"source_ip": "203.0.113.0/24",
"port": "22,80,443",
"description": "allow admin and web"
}
]
}
```
| Field | Description |
| --- | --- |
| `enabled` | Whether the container firewall is enabled. |
| `default_action` | Default action: `ACCEPT` or `DROP`. |
| `rules[].id` | Optional. Omit for new rules and the backend will generate one. |
| `rules[].direction` | Direction: `in` or `out`. |
| `rules[].protocol` | Protocol: `tcp`, `udp`, `icmp`, or `all`. |
| `rules[].action` | Action: `ACCEPT` or `DROP`. |
| `rules[].network` | Network type: `ipv4`, `ipv6`, or `all`. |
| `rules[].source_ip` | Optional source IP, CIDR, or address range. |
| `rules[].port` | Optional. Supported only for `tcp`/`udp`; examples: `22`, `80,443`, or `8000-9000`. |
| `rules[].description` | Optional note. |
## API Key Create and Update
`POST /api/v1/api-keys` and `PATCH /api/v1/api-keys/{id}` use the same field shape. `name` is required when creating a key; updates overwrite the fields you send.
```json
{
"name": "Automation",
"ip_whitelist": "198.51.100.23,203.0.113.0/24",
"scopes": ["dashboard:read", "container:read", "container:power"],
"expires_at": "2026-12-31 23:59:59",
"disabled": false,
"container_uuids": ["00000000-0000-4000-8000-000000000005"]
}
```
| Field | Description |
| --- | --- |
| `name` | API key name. Required when creating a key. |
| `ip_whitelist` | Optional allowed source IPs/CIDRs, comma-separated. Empty means no IP restriction. |
| `scopes` | Optional permission scopes. If omitted, the default read-only scopes are used. `*` grants all permissions. |
| `expires_at` | Optional expiration time. Empty means no expiration. |
| `disabled` | Whether this key is disabled. |
| `container_uuids` | Optional container allowlist that limits the key to specific containers. |
## Panel Access Source Policy
Use `GET /api/v1/access-policy` to read the panel source allowlist and `PUT /api/v1/access-policy` to update it. Both endpoints require `admin:access`. The policy covers panel pages, login endpoints, and every API.
```json
{
"enabled": true,
"allowed_sources": [
"203.0.113.10",
"192.168.1.0/24",
"2001:db8::/32"
],
"trusted_proxies": [
"127.0.0.1"
]
}
```
Both lists accept IPv4, IPv6, and CIDR values. The backend only uses `X-Forwarded-For`, `X-Real-IP`, or `CF-Connecting-IP` when the direct peer matches `trusted_proxies`, so untrusted clients cannot bypass the policy by spoofing those headers. An enabled policy requires at least one allowed source, and the API rejects changes that exclude the current administrator source. Direct loopback access remains available as a CLI/SSH recovery path.
## 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/host-report` | Host inspection report |
| 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 (recommended) |
| GET | `/api/v1/containers/list` | Compatible GET form for 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; accepts `host_ip` to check a specific host IP |
| 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/containers/{id}/firewall` | Get container firewall settings |
| PUT | `/api/v1/containers/{id}/firewall` | Update container firewall settings |
| 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 |
| GET | `/api/v1/images/enabled` | Enabled and downloaded images; supports `type=lxc\|kvm` |
| POST | `/api/v1/images/custom` | Add a third-party LXC/KVM image source |
| DELETE | `/api/v1/images/custom` | Remove a third-party LXC/KVM image source and cache |
| 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 |
| GET | `/api/v1/language` | Current panel language |
| POST/PUT | `/api/v1/language` | Update panel language |
| GET | `/api/v1/ssl` | SSL settings (requires admin permission / `admin:access`) |
| PUT | `/api/v1/ssl` | Update SSL settings (requires admin permission / `admin:access`) |
| GET | `/api/v1/webssh-origins` | WebSSH Origin allowlist (requires admin permission / `admin:access`) |
| PUT | `/api/v1/webssh-origins` | Update WebSSH Origin allowlist (requires admin permission / `admin:access`) |
| 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/host-report": {
"success": true,
"data": {
"generated_at": "2026-06-12 10:00:00",
"summary": { "status": "ok", "warnings": 0 },
"host": { "hostname": "node-1", "kernel": "6.8.0" },
"resources": { "cpu_cores": 8, "ram_total_mb": 31825, "disk_total_gb": 1750.49 },
"network": { "public_ipv4": "203.0.113.10", "public_ipv6": "2001:db8:100::2" }
}
},
"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,
"network_down_mbps": 100,
"network_up_mbps": 50,
"io_read_mbps": 120,
"io_write_mbps": 80,
"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" }
]
}
]
},
"GET /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/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": 1024,
"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?host_ip=203.0.113.10": {
"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/containers/{id}/firewall": {
"success": true,
"data": {
"enabled": true,
"default_action": "DROP",
"rules": [
{ "id": "a1b2c3d4", "direction": "in", "protocol": "tcp", "action": "ACCEPT", "network": "ipv4", "source_ip": "203.0.113.0/24", "port": "22,80,443", "description": "allow admin and web" }
]
}
},
"PUT /api/v1/containers/{id}/firewall": {
"success": true,
"message": "Firewall updated",
"data": { "enabled": true, "default_action": "DROP", "rules": [] }
},
"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 }
]
},
"GET /api/v1/images/enabled?type=lxc": {
"success": true,
"data": [
{ "id": "ubuntu-noble", "name": "Ubuntu 24.04", "distro": "ubuntu", "release": "noble", "arch": "amd64", "variant": "default", "description": "Ubuntu 24.04 LTS", "type": "lxc" }
]
},
"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 adjusted to 16384 MB",
"data": { "total_mb": 16383, "used_mb": 0, "free_mb": 16383, "enabled": true, "swap_file": "/swapfile" }
},
"GET /api/v1/language": {
"success": true,
"data": { "language": "zh" }
},
"PUT /api/v1/language": {
"success": true,
"data": { "language": "en" }
},
"GET /api/v1/ssl": {
"success": true,
"data": { "enabled": true, "mode": "self-signed", "target": "panel.example.com", "detected_host": "panel.example.com", "needs_restart": false }
},
"PUT /api/v1/ssl": {
"success": true,
"message": "SSL settings saved",
"data": { "enabled": true, "mode": "self-signed", "target": "panel.example.com", "needs_restart": true }
},
"GET /api/v1/webssh-origins": {
"success": true,
"data": { "origins": ["https://panel.example.com"], "current_origin": "https://panel.example.com" }
},
"PUT /api/v1/webssh-origins": {
"success": true,
"message": "Origin allowlist saved",
"data": { "origins": ["https://panel.example.com"], "current_origin": "https://panel.example.com" }
},
"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": ["*"], "expires_at": "", "disabled": false, "container_uuids": [], "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...", "ip_whitelist": "198.51.100.23", "scopes": ["dashboard:read", "container:read"], "expires_at": "2026-12-31 23:59:59", "disabled": false, "container_uuids": ["00000000-0000-4000-8000-000000000005"] }
},
"PATCH /api/v1/api-keys/{id}": {
"success": true,
"data": { "id": "a1b2c3d4", "name": "Automation", "prefix": "clicd_sk_xxxx...", "scopes": ["dashboard:read", "container:read"], "expires_at": "2026-12-31 23:59:59", "disabled": false, "container_uuids": ["00000000-0000-4000-8000-000000000005"] }
},
"DELETE /api/v1/api-keys/{id}": {
"success": true,
"message": "API key deleted"
}
}
```
+83
View File
@@ -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.
+27
View File
@@ -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
```
+21
View File
@@ -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.
+29
View File
@@ -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.
+61
View File
@@ -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.
+31
View File
@@ -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.
+31
View File
@@ -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.
+28
View File
@@ -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.
+47
View File
@@ -0,0 +1,47 @@
# 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
```
## Panel Access Allowlist CLI
```bash
# Show the current policy
clicd access-policy show
# Allow selected addresses and networks; add reverse proxies when needed
clicd access-policy set \
--allow "203.0.113.10,192.168.1.0/24,2001:db8::/32" \
--trusted-proxy "127.0.0.1"
# Disable source restrictions
clicd access-policy disable
```
The same controls are available from the "Panel access allowlist" item in `clicd cli`. Both paths persist the setting and restart the running panel service automatically.
## 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.
+48
View File
@@ -0,0 +1,48 @@
# 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/amd64 or ARM64/aarch64 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 installer asks for separate LXC and KVM NAT private subnets. Press Enter to scan host routes, interfaces, bridges, and libvirt networks and select non-overlapping RFC1918 `/24` networks, or enter a CIDR such as `172.28.40.0/24`. For unattended installation, set `CLICD_LXC_SUBNET` and `CLICD_KVM_SUBNET`.
The script defaults to `CLICD_VERSION=latest` and downloads `clicd-linux-amd64.tar.gz` or `clicd-linux-arm64.tar.gz` from `releases/latest` according to the host architecture.
## 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.
+29
View File
@@ -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/ARM64 release artifacts. The installer fetches the latest release by default.
+36
View File
@@ -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.
+43
View File
@@ -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.
+23
View File
@@ -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.
---
+46
View File
@@ -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.
+29
View File
@@ -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 or ARM64 artifact from `releases/latest` according to the host architecture.
## 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.
+46
View File
@@ -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.
+807 -63
View File
@@ -1,6 +1,6 @@
# API 集成
CLICD 对外推荐使用 `/api/v1` 接口。旧版未带版本号的接口主要用于 Web 面板和兼容场景,新接入请优先使用 `/api/v1`
CLICD 继续兼容旧版 `/api` 接口,已有对接无需修改。新接入推荐使用 `/api/v1` 接口,下面的清单均为 v1;容器列表推荐 `GET /api/v1/containers`
## 认证
@@ -14,8 +14,207 @@ curl -H "X-API-Key: YOUR_API_KEY" https://panel.example.com/api/v1/containers
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": "",
"network_down_mbps": 100,
"network_up_mbps": 50,
"io_read_mbps": 120,
"io_write_mbps": 80
}
```
字段说明:
| 字段 | 说明 |
| --- | --- |
| `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 公钥。 |
| `network_down_mbps` | 可选;容器下行/下载带宽限制,单位 Mbps,`0` 表示不限制。 |
| `network_up_mbps` | 可选;容器上行/上传带宽限制,单位 Mbps,`0` 表示不限制。 |
| `io_read_mbps` | 可选;磁盘读取限速,单位 MB/s,`0` 表示不限制。 |
| `io_write_mbps` | 可选;磁盘写入限速,单位 MB/s,`0` 表示不限制。 |
| `network_bw_mbps` | 兼容旧字段;同时设置上下行对称带宽,新接入推荐使用拆分字段。 |
| `io_speed_mbps` | 兼容旧字段;同时设置读写对称 IO 限速,新接入推荐使用拆分字段。 |
重装示例:
```json
{
"template_id": "debian-bookworm",
"ssh_auth_mode": "keep",
"ssh_password": "",
"ssh_public_key": ""
}
```
`keep` 仅用于重装,表示沿用当前 SSH 密码。Windows KVM 镜像会忽略 Linux SSH 公钥相关字段。
## 资源限制与流量限制
`PUT /api/v1/containers/{id}/resource-limit` 支持按字段局部更新;未传的字段保持不变。
```json
{
"vcpu": 2,
"ram_mb": 1024,
"network_down_mbps": 100,
"network_up_mbps": 50,
"io_read_mbps": 120,
"io_write_mbps": 80
}
```
旧版 `network_bw_mbps``io_speed_mbps` 仍可用,分别表示上下行对称带宽和读写对称 IO 限速。新接入建议使用拆分字段,以便分别控制下载/上传和读取/写入。
`PUT /api/v1/containers/{id}/traffic-limit` 请求体:
```json
{
"traffic_mode": "total",
"monthly_traffic_gb": 1024,
"traffic_in_gb": 0,
"traffic_out_gb": 0
}
```
| 字段 | 说明 |
| --- | --- |
| `traffic_mode` | 流量限制模式;常用 `total` 表示总量限制,`split` 表示入站/出站分别限制。 |
| `monthly_traffic_gb` | `total` 模式下的月总流量额度,单位 GB`0` 表示不限制。 |
| `traffic_in_gb` | `split` 模式下的月入站额度,单位 GB`0` 表示不限制。 |
| `traffic_out_gb` | `split` 模式下的月出站额度,单位 GB`0` 表示不限制。 |
## 容器防火墙
容器防火墙通过 `GET /api/v1/containers/{id}/firewall` 读取,通过 `PUT /api/v1/containers/{id}/firewall` 更新。容器运行中更新时会立即应用规则。
更新示例:
```json
{
"enabled": true,
"default_action": "DROP",
"rules": [
{
"direction": "in",
"protocol": "tcp",
"action": "ACCEPT",
"network": "ipv4",
"source_ip": "203.0.113.0/24",
"port": "22,80,443",
"description": "allow admin and web"
}
]
}
```
| 字段 | 说明 |
| --- | --- |
| `enabled` | 是否启用容器防火墙。 |
| `default_action` | 默认动作:`ACCEPT``DROP`。 |
| `rules[].id` | 可选;新规则可省略,后端会自动生成。 |
| `rules[].direction` | 方向:`in``out`。 |
| `rules[].protocol` | 协议:`tcp``udp``icmp``all`。 |
| `rules[].action` | 动作:`ACCEPT``DROP`。 |
| `rules[].network` | 网络类型:`ipv4``ipv6``all`。 |
| `rules[].source_ip` | 可选;源 IP、CIDR 或地址范围。 |
| `rules[].port` | 可选;仅 `tcp`/`udp` 支持,可写 `22``80,443``8000-9000`。 |
| `rules[].description` | 可选备注。 |
## API Key 创建与更新
`POST /api/v1/api-keys``PATCH /api/v1/api-keys/{id}` 使用相同的字段结构。创建时 `name` 必填;更新时根据需要覆盖字段。
```json
{
"name": "Automation",
"ip_whitelist": "198.51.100.23,203.0.113.0/24",
"scopes": ["dashboard:read", "container:read", "container:power"],
"expires_at": "2026-12-31 23:59:59",
"disabled": false,
"container_uuids": ["00000000-0000-4000-8000-000000000005"]
}
```
| 字段 | 说明 |
| --- | --- |
| `name` | API Key 名称;创建时必填。 |
| `ip_whitelist` | 可选;允许的来源 IP/CIDR,多个值用逗号分隔;空值表示不限制。 |
| `scopes` | 可选;权限范围。省略时使用默认只读范围,传 `*` 表示全部权限。 |
| `expires_at` | 可选;过期时间,空值表示不过期。 |
| `disabled` | 是否禁用该 Key。 |
| `container_uuids` | 可选;限制该 Key 只能访问指定容器。 |
## 面板访问来源策略
`GET /api/v1/access-policy` 读取面板访问白名单,`PUT /api/v1/access-policy` 更新策略。两者均需要 `admin:access` 权限。策略覆盖面板页面、登录入口和全部 API。
```json
{
"enabled": true,
"allowed_sources": [
"203.0.113.10",
"192.168.1.0/24",
"2001:db8::/32"
],
"trusted_proxies": [
"127.0.0.1"
]
}
```
`allowed_sources``trusted_proxies` 均支持 IPv4、IPv6 及 CIDR。只有直接连接来源命中 `trusted_proxies` 时,后端才会使用 `X-Forwarded-For``X-Real-IP``CF-Connecting-IP`;其他客户端伪造这些请求头不会绕过白名单。启用策略时至少要配置一个允许来源,且接口会拒绝排除当前管理来源的配置。本机回环直连保留为 CLI/SSH 故障恢复通道。
## Python 示例
获取容器列表:
```python
import requests
@@ -30,9 +229,7 @@ session.headers.update({
resp = session.get(f"{BASE_URL}/api/v1/containers", timeout=15)
resp.raise_for_status()
containers = resp.json()
print(containers)
print(resp.json())
```
创建端口映射:
@@ -45,10 +242,10 @@ API_KEY = "YOUR_API_KEY"
CONTAINER_ID = "example-vm"
payload = {
"name": "web",
"protocol": "tcp",
"host_port": 18080,
"container_port": 80,
"description": "web",
}
resp = requests.post(
@@ -61,76 +258,623 @@ resp.raise_for_status()
print(resp.json())
```
## 返回结构示例
## 接口清单
容器列表:
```json
{
"success": true,
"data": [
{
"id": 5,
"uuid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"name": "example-vm",
"status": "running",
"ip": "10.0.3.25",
"ipv6": "2001:db8:100::1005",
"cpu_limit": 2,
"memory_limit": 2048,
"disk_limit": 20480,
"traffic_limit": 107374182400,
"expires_at": "2026-12-31 23:59:59"
}
]
}
```
任务队列:
```json
{
"success": true,
"data": [
{
"id": "task-13",
"type": "restart",
"status": "running",
"created_at": "2026-06-09T10:00:00+08:00"
}
]
}
```
WebSSH 票据:
```json
{
"success": true,
"data": {
"ticket": "***60秒有效票据***"
}
}
```
## 常用接口
### 总览
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| GET | `/api/v1/dashboard` | 控制面板统计 |
| GET | `/api/v1/host-info` | 主机资源 |
| GET | `/api/v1/containers` | 容器列表 |
| GET | `/api/v1/host-report` | 主机巡检报告 |
| 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` | 容器列表(推荐) |
| GET | `/api/v1/containers/list` | 容器列表兼容 GET 写法 |
| 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/tasks` | 任务队列 |
| 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` | 随机可用端口;可传 `host_ip` 查询指定宿主机 IP |
| 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/containers/{id}/firewall` | 获取容器防火墙设置 |
| PUT | `/api/v1/containers/{id}/firewall` | 更新容器防火墙设置 |
| 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` | 镜像管理列表 |
| GET | `/api/v1/snapshots` | 快照总览 |
| GET | `/api/v1/images/enabled` | 已启用且已下载的镜像;支持 `type=lxc\|kvm` |
| POST | `/api/v1/images/custom` | 添加第三方 LXC/KVM 镜像源 |
| DELETE | `/api/v1/images/custom` | 移除第三方 LXC/KVM 镜像源及缓存 |
| 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` | 安全告警 |
| GET | `/api/v1/audit-logs` | 操作日志 |
| GET | `/api/v1/api-keys` | API Key 列表 |
| 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 |
| GET | `/api/v1/language` | 当前面板语言 |
| POST/PUT | `/api/v1/language` | 更新面板语言 |
| GET | `/api/v1/ssl` | SSL 设置(需管理员权限 / `admin:access` |
| PUT | `/api/v1/ssl` | 更新 SSL 设置(需管理员权限 / `admin:access` |
| GET | `/api/v1/webssh-origins` | WebSSH Origin 白名单(需管理员权限 / `admin:access` |
| PUT | `/api/v1/webssh-origins` | 更新 WebSSH Origin 白名单(需管理员权限 / `admin:access` |
| POST | `/api/v1/batch-create` | 批量创建容器 |
| POST | `/api/v1/batch-action` | 批量开关机/删除/重装 |
| POST | `/api/v1/ssh-ticket` | 创建 WebSSH 票据 |
| POST | `/api/v1/vnc-ticket` | 创建 WebVNC 票据 |
完整接口清单请以面板内“API 集成”页面为准。
### 账号与日志
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| 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/host-report": {
"success": true,
"data": {
"generated_at": "2026-06-12 10:00:00",
"summary": { "status": "ok", "warnings": 0 },
"host": { "hostname": "node-1", "kernel": "6.8.0" },
"resources": { "cpu_cores": 8, "ram_total_mb": 31825, "disk_total_gb": 1750.49 },
"network": { "public_ipv4": "203.0.113.10", "public_ipv6": "2001:db8:100::2" }
}
},
"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,
"network_down_mbps": 100,
"network_up_mbps": 50,
"io_read_mbps": 120,
"io_write_mbps": 80,
"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" }
]
}
]
},
"GET /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/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": 1024,
"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?host_ip=203.0.113.10": {
"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/containers/{id}/firewall": {
"success": true,
"data": {
"enabled": true,
"default_action": "DROP",
"rules": [
{ "id": "a1b2c3d4", "direction": "in", "protocol": "tcp", "action": "ACCEPT", "network": "ipv4", "source_ip": "203.0.113.0/24", "port": "22,80,443", "description": "allow admin and web" }
]
}
},
"PUT /api/v1/containers/{id}/firewall": {
"success": true,
"message": "Firewall updated",
"data": { "enabled": true, "default_action": "DROP", "rules": [] }
},
"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 }
]
},
"GET /api/v1/images/enabled?type=lxc": {
"success": true,
"data": [
{ "id": "ubuntu-noble", "name": "Ubuntu 24.04", "distro": "ubuntu", "release": "noble", "arch": "amd64", "variant": "default", "description": "Ubuntu 24.04 LTS", "type": "lxc" }
]
},
"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" }
},
"GET /api/v1/language": {
"success": true,
"data": { "language": "zh" }
},
"PUT /api/v1/language": {
"success": true,
"data": { "language": "en" }
},
"GET /api/v1/ssl": {
"success": true,
"data": { "enabled": true, "mode": "self-signed", "target": "panel.example.com", "detected_host": "panel.example.com", "needs_restart": false }
},
"PUT /api/v1/ssl": {
"success": true,
"message": "SSL settings saved",
"data": { "enabled": true, "mode": "self-signed", "target": "panel.example.com", "needs_restart": true }
},
"GET /api/v1/webssh-origins": {
"success": true,
"data": { "origins": ["https://panel.example.com"], "current_origin": "https://panel.example.com" }
},
"PUT /api/v1/webssh-origins": {
"success": true,
"message": "Origin allowlist saved",
"data": { "origins": ["https://panel.example.com"], "current_origin": "https://panel.example.com" }
},
"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": ["*"], "expires_at": "", "disabled": false, "container_uuids": [], "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...", "ip_whitelist": "198.51.100.23", "scopes": ["dashboard:read", "container:read"], "expires_at": "2026-12-31 23:59:59", "disabled": false, "container_uuids": ["00000000-0000-4000-8000-000000000005"] }
},
"PATCH /api/v1/api-keys/{id}": {
"success": true,
"data": { "id": "a1b2c3d4", "name": "Automation", "prefix": "clicd_sk_xxxx...", "scopes": ["dashboard:read", "container:read"], "expires_at": "2026-12-31 23:59:59", "disabled": false, "container_uuids": ["00000000-0000-4000-8000-000000000005"] }
},
"DELETE /api/v1/api-keys/{id}": {
"success": true,
"message": "API key deleted"
}
}
```
+10
View File
@@ -25,6 +25,14 @@ 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
@@ -37,6 +45,8 @@ DELETE /api/v1/containers/{id}/delete
开关机、重装、删除等操作会进入任务队列。调用后可通过 `GET /api/v1/tasks` 查看执行状态。
重装 Linux 系统时可传 `ssh_auth_mode``ssh_password``ssh_public_key``ssh_auth_mode=keep` 表示沿用当前 SSH 密码;不传这些字段时保持旧行为。
## 资源与流量
容器详情页支持查看资源用量,调整流量限制、资源限制和到期时间。
+23 -1
View File
@@ -1,6 +1,6 @@
# 网络与路由
CLICD 提供 NAT4 端口映射、随机可用端口、IPv6 状态检查和 IPv6 分配能力。
CLICD 提供 NAT4 端口映射、随机可用端口、公网 IPv4 分配、IPv6 状态检查和 IPv6 分配能力。创建容器时可以只分配 NAT、只分配公网 IPv4、只分配 IPv6,或按需混合使用。
## NAT4
@@ -30,6 +30,28 @@ 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
+17
View File
@@ -21,6 +21,23 @@ systemctl restart clicd
journalctl -u clicd -n 100 --no-pager
```
## 面板访问白名单 CLI
```bash
# 查看当前策略
clicd access-policy show
# 仅允许指定 IP/网段;反向代理地址按需填写
clicd access-policy set \
--allow "203.0.113.10,192.168.1.0/24,2001:db8::/32" \
--trusted-proxy "127.0.0.1"
# 关闭白名单限制
clicd access-policy disable
```
也可以运行 `clicd cli`,在交互菜单中选择“面板访问白名单”。直接命令和交互菜单都会保存配置,并在服务运行时自动重启面板。
## 安全建议
- 不要把 Web 面板直接暴露给不可信来源。
+4 -2
View File
@@ -4,7 +4,7 @@ CLICD 提供一键安装脚本。脚本默认安装 GitHub Releases 的最新版
## 环境要求
- Linux x86_64 宿主机。
- Linux x86_64/amd64 或 ARM64/aarch64 宿主机。
- root 权限。
- systemd。
- 网络可访问 GitHub Release 下载地址。
@@ -17,7 +17,9 @@ CLICD 提供一键安装脚本。脚本默认安装 GitHub Releases 的最新版
curl -fsSL https://raw.githubusercontent.com/MengMengCode/CLICD/main/install.sh | sudo sh
```
脚本当前默认使用 `CLICD_VERSION=latest`,也就是下载 `releases/latest` 对应的 `clicd-linux-amd64.tar.gz`
安装器会分别询问 LXC 与 KVM 的 NAT 私网网段。直接回车时,脚本会扫描宿主机路由、网卡、网桥和 libvirt 网络,自动选择未冲突的 RFC1918 `/24` 网段;也可以输入 `172.28.40.0/24` 这类 CIDR。非交互安装可设置 `CLICD_LXC_SUBNET``CLICD_KVM_SUBNET`
脚本当前默认使用 `CLICD_VERSION=latest`,会按宿主架构下载 `releases/latest` 对应的 `clicd-linux-amd64.tar.gz``clicd-linux-arm64.tar.gz`
## 安装指定版本
+1 -1
View File
@@ -26,4 +26,4 @@ CLICD 是一个面向 LXC/KVM 的轻量虚拟化管理面板。它把常见宿
- 后端: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。
- 发布:GitHub Actions 构建 Linux AMD64/ARM64 release 产物,安装脚本默认拉取最新 Release。

Some files were not shown because too many files have changed in this diff Show More