Compare commits

...

37 Commits

Author SHA1 Message Date
MengMengCode 33603f5776 release: v1.1.6 2026-06-09 00:12:19 +08:00
MengMengCode 9ad7bcc97a 完善API文档 2026-06-09 00:10:45 +08:00
MengMengCode f3a1687a18 修复了一些已知问题 2026-06-08 21:18:17 +08:00
MengMengCode 49b13af91c release: v1.1.5 2026-06-08 20:10:34 +08:00
MengMengCode e79609281f 增强API集成能力,划分KEY功能权限 2026-06-08 19:25:55 +08:00
MengMengCode 2fa130a2b6 标记并清理 CLICD 创建的 libvirt default 网络 2026-06-08 16:21:23 +08:00
MengMengCode 14d2192b05 完善卸载网络规则清理 2026-06-08 16:18:24 +08:00
MengMengCode 9f5ad94a83 清理卸载时的 LXC 镜像缓存 2026-06-08 16:10:55 +08:00
MengMengCode ac6587f2bc 增强安装脚本发行版下载回退 2026-06-08 16:03:32 +08:00
MengMengCode 6fad37b844 修复安装脚本下载失败处理 2026-06-08 15:55:17 +08:00
MengMengCode d0eb92eaab 修复 2026-06-08 15:49:42 +08:00
MengMengCode 5207082cd1 release: v1.1.4 2026-06-08 15:44:58 +08:00
MengMengCode 608b50f18a 修复了一些功能 2026-06-08 15:44:35 +08:00
MengMengCode b58a6b1030 release: v1.1.3 2026-06-08 14:41:51 +08:00
MengMengCode 366f889a8c 优化安装脚本执行逻辑 2026-06-08 14:40:06 +08:00
MengMengCode 814441e9a0 release: v1.1.2 2026-06-08 02:24:04 +08:00
MengMengCode aed11af105 修复了一些已知问题 2026-06-08 02:23:48 +08:00
MengMengCode 3d95bb33c1 Merge branch 'main' of https://github.com/MengMengCode/CLICD 2026-06-08 01:21:12 +08:00
MengMengCode ade1c6c093 优化功能体验 2026-06-08 01:21:10 +08:00
Meng Meng 5c4cc1cab3 Merge pull request #4 from MengMengCode/dependabot/npm_and_yarn/frontend/vite-8.0.16
build(deps-dev): bump vite from 5.4.21 to 8.0.16 in /frontend
2026-06-07 23:42:42 +08:00
copilot-swe-agent[bot] 109e47170f fix: resolve frontend dependency conflicts for Vite 8 build 2026-06-07 15:40:09 +00:00
dependabot[bot] 34637cc79d build(deps-dev): bump vite from 5.4.21 to 8.0.16 in /frontend
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 5.4.21 to 8.0.16.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.0.16/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 8.0.16
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-07 15:31:31 +00:00
MengMengCode 7d48889eea release: v1.1.1 2026-06-07 23:29:06 +08:00
MengMengCode 2bcdb9e095 Merge branch 'main' of https://github.com/MengMengCode/CLICD 2026-06-07 23:10:21 +08:00
MengMengCode 2ab42e7f57 支持部署KVM XFCE桌面系统 2026-06-07 23:10:19 +08:00
Meng Meng 3257cbb2a3 Merge pull request #2 from MengMengCode/dependabot/go_modules/backend/golang.org/x/crypto-0.45.0
build(deps): bump golang.org/x/crypto from 0.28.0 to 0.45.0 in /backend
2026-06-07 22:06:55 +08:00
dependabot[bot] 1ff5d7a85e build(deps): bump golang.org/x/crypto from 0.28.0 to 0.45.0 in /backend
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.28.0 to 0.45.0.
- [Commits](https://github.com/golang/crypto/compare/v0.28.0...v0.45.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-07 13:29:47 +00:00
Meng Meng a99781d418 Merge pull request #1 from MengMengCode/dependabot/go_modules/backend/github.com/golang-jwt/jwt/v5-5.2.2
Bump github.com/golang-jwt/jwt/v5 from 5.2.1 to 5.2.2 in /backend
2026-06-07 21:27:51 +08:00
MengMengCode b993e57d05 fix: repair login page encoding 2026-06-07 21:19:24 +08:00
MengMengCode 7ae0c91813 release: v1.1.0 2026-06-07 21:14:31 +08:00
MengMengCode aab58aca6e release: v1.1.0 2026-06-07 21:11:53 +08:00
MengMengCode 0b27604f95 FIX 2026-06-07 21:11:29 +08:00
dependabot[bot] 460614e274 Bump github.com/golang-jwt/jwt/v5 from 5.2.1 to 5.2.2 in /backend
Bumps [github.com/golang-jwt/jwt/v5](https://github.com/golang-jwt/jwt) from 5.2.1 to 5.2.2.
- [Release notes](https://github.com/golang-jwt/jwt/releases)
- [Commits](https://github.com/golang-jwt/jwt/compare/v5.2.1...v5.2.2)

---
updated-dependencies:
- dependency-name: github.com/golang-jwt/jwt/v5
  dependency-version: 5.2.2
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-07 13:11:00 +00:00
MengMengCode 007811ab41 release: v1.1.0 2026-06-07 21:09:37 +08:00
MengMengCode 95af3e44f2 优化了一些功能 2026-06-07 21:08:51 +08:00
MengMengCode 2ad17fa520 修复了一些已知问题 2026-06-07 20:16:00 +08:00
MengMengCode 08a1a057e7 Update Disclaimer 2026-06-07 19:09:23 +08:00
52 changed files with 9093 additions and 1725 deletions
+2
View File
@@ -58,6 +58,7 @@ backend/tmp/
*.swp *.swp
*.swo *.swo
*~ *~
*.claude/
# OS # OS
.DS_Store .DS_Store
@@ -65,3 +66,4 @@ Thumbs.db
linux.txt linux.txt
push-release.ps1 push-release.ps1
deploy.ps1 deploy.ps1
backend/clicd
+23 -5
View File
@@ -53,6 +53,28 @@ curl -fsSL https://raw.githubusercontent.com/MengMengCode/CLICD/main/install.sh
![alt text](/img/image-1.png) ![alt text](/img/image-1.png)
![alt text](/img/image-2.png) ![alt text](/img/image-2.png)
## Disclaimer/免责声明
This open-source software does not distribute Windows system images, nor does it provide any means to bypass or circumvent Windows activation mechanisms.
All download links provided within the software point to resources officially supplied by Microsoft. Users of this software are responsible for obtaining the appropriate licenses from Microsoft before using any Windows operating system downloaded through these links. This project does not bypass activation requirements for installed systems, nor does it assume any responsibility for the consequences of users' actions when using this software.
This open-source software is intended solely for educational purposes, specifically for learning the principles of LXC and KVM. The copyright for the Windows logo and related icons belongs to Microsoft/Windows.
本开源软件不提供任何 Windows 操作系统镜像的分发服务,也不包含任何绕过、破解或免除 Windows 激活机制的功能。
软件内涉及的 Windows 系统下载链接均由微软官方提供。使用者在下载、安装和使用相关 Windows 系统时,应自行向微软或其授权渠道购买并获得相应的软件许可。本项目不会对安装后的 Windows 系统进行任何形式的激活绕过、破解或免激活处理。
对于使用者因使用本软件而产生的任何行为及其后果,包括但不限于软件许可、系统使用、数据丢失、法律责任或其他相关问题,本项目及其开发者不承担任何责任。
本开源软件仅供学习和研究 LXC、KVM 等虚拟化技术原理之目的使用,不得用于任何违反适用法律法规、软件许可协议或第三方权益的行为。
本软件中涉及的 Windows 名称、标识、图标及相关知识产权均归 Microsoft Corporation 及其权利人所有。本项目与微软公司不存在任何关联、授权或合作关系。
## Thanks/鸣谢
- [Linux.do](https://linux.do) — 一个充满灵感的科技社区
## Star History ## Star History
<a href="https://www.star-history.com/?repos=MengMengCode%2FCLICD&type=date&legend=top-left"> <a href="https://www.star-history.com/?repos=MengMengCode%2FCLICD&type=date&legend=top-left">
@@ -61,8 +83,4 @@ curl -fsSL https://raw.githubusercontent.com/MengMengCode/CLICD/main/install.sh
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=MengMengCode/CLICD&type=date&legend=top-left" /> <source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=MengMengCode/CLICD&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=MengMengCode/CLICD&type=date&legend=top-left" /> <img alt="Star History Chart" src="https://api.star-history.com/chart?repos=MengMengCode/CLICD&type=date&legend=top-left" />
</picture> </picture>
</a> </a>
## 鸣谢
- [Linux.do](https://linux.do) — 一个充满灵感的科技社区
+26 -5
View File
@@ -1,12 +1,33 @@
module clicd module clicd
go 1.22.0 go 1.24.0
toolchain go1.24.5
require ( require (
github.com/golang-jwt/jwt/v5 v5.2.1 github.com/golang-jwt/jwt/v5 v5.2.2
github.com/gorilla/websocket v1.5.3 github.com/gorilla/websocket v1.5.3
golang.org/x/crypto v0.28.0 golang.org/x/crypto v0.45.0
golang.org/x/term v0.28.0 golang.org/x/term v0.37.0
) )
require golang.org/x/sys v0.29.0 // indirect require (
golang.org/x/sys v0.38.0
modernc.org/sqlite v1.29.10
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/exp v0.0.0-20231108232855-2478ac86f678 // indirect
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
modernc.org/libc v1.61.13 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.8.2 // indirect
modernc.org/strutil v1.2.1 // indirect
modernc.org/token v1.1.0 // indirect
)
+59 -8
View File
@@ -1,10 +1,61 @@
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek= github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
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/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=
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/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=
modernc.org/cc/v4 v4.24.4/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.23.16 h1:Z2N+kk38b7SfySC1ZkpGLN2vthNJP1+ZzGZIlH7uBxo=
modernc.org/ccgo/v4 v4.23.16/go.mod h1:nNma8goMTY7aQZQNTyN9AIoJfxav4nvTnvKThAeMDdo=
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
modernc.org/gc/v2 v2.6.3 h1:aJVhcqAte49LF+mGveZ5KPlsp4tdGdAOT4sipJXADjw=
modernc.org/gc/v2 v2.6.3/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI=
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
modernc.org/libc v1.61.13 h1:3LRd6ZO1ezsFiX1y+bHd1ipyEHIJKvuprv0sLTBwLW8=
modernc.org/libc v1.61.13/go.mod h1:8F/uJWL/3nNil0Lgt1Dpz+GgkApWh04N3el3hxJcA6E=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.8.2 h1:cL9L4bcoAObu4NkxOlKWBWtNHIsnnACGF/TbqQ6sbcI=
modernc.org/memory v1.8.2/go.mod h1:ZbjSvMO5NQ1A2i3bWeDiVMxIorXwdClKE/0SZ+BMotU=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.29.10 h1:3u93dz83myFnMilBGCOLbr+HjklS6+5rJLx4q86RDAg=
modernc.org/sqlite v1.29.10/go.mod h1:ItX2a1OVGgNsFh6Dv60JQvGfJfTPHPVpV6DF59akYOA=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+363 -124
View File
@@ -2,122 +2,293 @@ package api
import ( import (
"crypto/rand" "crypto/rand"
"crypto/sha256"
"crypto/subtle" "crypto/subtle"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"fmt"
"net" "net"
"net/http" "net/http"
"strconv"
"strings" "strings"
"time" "time"
"clicd/internal/config" "clicd/internal/config"
"golang.org/x/crypto/argon2"
) )
type ApiKey struct { type ApiKey struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Key string `json:"key,omitempty"` Key string `json:"key,omitempty"`
Prefix string `json:"prefix"` Prefix string `json:"prefix"`
IPWhitelist string `json:"ip_whitelist"` IPWhitelist string `json:"ip_whitelist"`
CreatedAt string `json:"created_at"` CreatedAt string `json:"created_at"`
LastUsed string `json:"last_used"` LastUsed string `json:"last_used"`
Scopes []string `json:"scopes,omitempty"`
ExpiresAt string `json:"expires_at,omitempty"`
Disabled bool `json:"disabled,omitempty"`
ContainerUUIDs []string `json:"container_uuids,omitempty"`
LastUsedIP string `json:"last_used_ip,omitempty"`
}
type apiKeyRequest struct {
Name string `json:"name"`
IPWhitelist string `json:"ip_whitelist"`
Scopes []string `json:"scopes"`
ExpiresAt string `json:"expires_at"`
Disabled bool `json:"disabled"`
ContainerUUIDs []string `json:"container_uuids"`
}
var defaultApiKeyScopes = []string{
"dashboard:read",
"container:read",
"task:read",
"image:read",
"snapshot:read",
"routing:read",
"ipv6:read",
"host:read",
} }
// HandleApiKeys handles GET (list) and POST (create) for API keys // HandleApiKeys handles GET (list) and POST (create) for API keys
func HandleApiKeys(w http.ResponseWriter, r *http.Request) { func HandleApiKeys(w http.ResponseWriter, r *http.Request) {
switch r.Method { switch r.Method {
case http.MethodGet: case http.MethodGet:
if !requireScope(w, r, "apikey:read") {
return
}
listApiKeys(w, r) listApiKeys(w, r)
case http.MethodPost: case http.MethodPost:
if !requireScope(w, r, "apikey:create") {
return
}
createApiKey(w, r) createApiKey(w, r)
default: default:
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
} }
} }
// HandleApiKeyDelete handles DELETE for a specific API key // HandleApiKeyDelete handles PATCH and DELETE for a specific API key
func HandleApiKeyDelete(w http.ResponseWriter, r *http.Request) { func HandleApiKeyDelete(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete { switch r.Method {
case http.MethodPatch:
if !requireScope(w, r, "apikey:update") {
return
}
updateApiKey(w, r)
case http.MethodDelete:
if !requireScope(w, r, "apikey:delete") {
return
}
deleteApiKey(w, r)
default:
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
} }
keyID := strings.TrimPrefix(r.URL.Path, "/api/api-keys/") }
if keyID == "" {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Key ID required"}) func apiKeyIDFromPath(path string) string {
return path = strings.TrimPrefix(path, "/api/api-keys/")
} path = strings.TrimPrefix(path, "/api/v1/api-keys/")
config.DeleteApiKey(keyID) return strings.Trim(path, "/")
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "API key deleted"})
} }
func listApiKeys(w http.ResponseWriter, r *http.Request) { func listApiKeys(w http.ResponseWriter, r *http.Request) {
keys := make([]ApiKey, 0) keys := make([]ApiKey, 0)
for _, k := range config.AppConfig.ApiKeys { for _, k := range config.AppConfig.ApiKeys {
keys = append(keys, ApiKey{ keys = append(keys, apiKeyResponse(k))
ID: k.ID,
Name: k.Name,
Prefix: k.Prefix,
IPWhitelist: k.IPWhitelist,
CreatedAt: k.CreatedAt,
LastUsed: k.LastUsed,
})
} }
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: keys}) jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: keys})
} }
func createApiKey(w http.ResponseWriter, r *http.Request) { func createApiKey(w http.ResponseWriter, r *http.Request) {
var req struct { var req apiKeyRequest
Name string `json:"name"` if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.Name) == "" {
IPWhitelist string `json:"ip_whitelist"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Name == "" {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Name is required"}) jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Name is required"})
return return
} }
if req.ExpiresAt != "" && !validApiKeyTime(req.ExpiresAt) {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid expiration date"})
return
}
// Generate key: clicd_sk_ + 32 hex chars // Generate key: clicd_sk_ + 32 hex chars
rawBytes := make([]byte, 16) rawBytes := make([]byte, 16)
rand.Read(rawBytes) if _, err := rand.Read(rawBytes); err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to generate API key"})
return
}
rawKey := "clicd_sk_" + hex.EncodeToString(rawBytes) rawKey := "clicd_sk_" + hex.EncodeToString(rawBytes)
keyHash, err := hashAPIKey(rawKey)
if err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to store API key"})
return
}
now := time.Now().Format("2006-01-02 15:04:05") now := time.Now().Format("2006-01-02 15:04:05")
scopes := normalizeRequestedScopes(req.Scopes, defaultApiKeyScopes)
key := config.ApiKeyConfig{ key := config.ApiKeyConfig{
ID: generateShortID(), ID: generateShortID(),
Name: req.Name, Name: strings.TrimSpace(req.Name),
KeyHash: hashKey(rawKey), KeyHash: keyHash,
Prefix: rawKey[:13] + "...", Prefix: rawKey[:13] + "...",
IPWhitelist: strings.TrimSpace(req.IPWhitelist), IPWhitelist: strings.TrimSpace(req.IPWhitelist),
CreatedAt: now, CreatedAt: now,
Scopes: scopes,
ExpiresAt: strings.TrimSpace(req.ExpiresAt),
Disabled: req.Disabled,
ContainerUUIDs: normalizeStringSlice(req.ContainerUUIDs),
} }
config.AppConfig.ApiKeys = append(config.AppConfig.ApiKeys, key) config.AppConfig.ApiKeys = append(config.AppConfig.ApiKeys, key)
config.SaveConfig() if err := config.SaveConfig(); err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to save API key"})
return
}
auditRequest(r, "apikey.create", key.Name, "scopes="+strings.Join(key.Scopes, ","), true, "")
resp := apiKeyResponse(key)
resp.Key = rawKey
jsonResponse(w, http.StatusCreated, APIResponse{ jsonResponse(w, http.StatusCreated, APIResponse{
Success: true, Success: true,
Message: "API key created. Save this key now - it won't be shown again.", Message: "API key created. Save this key now - it won't be shown again.",
Data: ApiKey{ Data: resp,
ID: key.ID,
Name: key.Name,
Key: rawKey,
Prefix: key.Prefix,
IPWhitelist: key.IPWhitelist,
CreatedAt: key.CreatedAt,
},
}) })
} }
func updateApiKey(w http.ResponseWriter, r *http.Request) {
keyID := apiKeyIDFromPath(r.URL.Path)
if keyID == "" {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Key ID required"})
return
}
var req apiKeyRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
return
}
if req.ExpiresAt != "" && !validApiKeyTime(req.ExpiresAt) {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid expiration date"})
return
}
for i := range config.AppConfig.ApiKeys {
if config.AppConfig.ApiKeys[i].ID != keyID {
continue
}
if strings.TrimSpace(req.Name) != "" {
config.AppConfig.ApiKeys[i].Name = strings.TrimSpace(req.Name)
}
config.AppConfig.ApiKeys[i].IPWhitelist = strings.TrimSpace(req.IPWhitelist)
if len(req.Scopes) > 0 {
config.AppConfig.ApiKeys[i].Scopes = normalizeStringSlice(req.Scopes)
}
config.AppConfig.ApiKeys[i].ExpiresAt = strings.TrimSpace(req.ExpiresAt)
config.AppConfig.ApiKeys[i].Disabled = req.Disabled
config.AppConfig.ApiKeys[i].ContainerUUIDs = normalizeStringSlice(req.ContainerUUIDs)
if err := config.SaveConfig(); err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to save API key"})
return
}
auditRequest(r, "apikey.update", config.AppConfig.ApiKeys[i].Name, "scopes="+strings.Join(config.AppConfig.ApiKeys[i].Scopes, ","), true, "")
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: apiKeyResponse(config.AppConfig.ApiKeys[i])})
return
}
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "API key not found"})
}
func deleteApiKey(w http.ResponseWriter, r *http.Request) {
keyID := apiKeyIDFromPath(r.URL.Path)
if keyID == "" {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Key ID required"})
return
}
name := keyID
for _, k := range config.AppConfig.ApiKeys {
if k.ID == keyID {
name = k.Name
break
}
}
config.DeleteApiKey(keyID)
auditRequest(r, "apikey.delete", name, "", true, "")
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "API key deleted"})
}
func apiKeyResponse(k config.ApiKeyConfig) ApiKey {
return ApiKey{
ID: k.ID,
Name: k.Name,
Prefix: k.Prefix,
IPWhitelist: k.IPWhitelist,
CreatedAt: k.CreatedAt,
LastUsed: k.LastUsed,
Scopes: normalizeApiKeyScopes(k.Scopes),
ExpiresAt: k.ExpiresAt,
Disabled: k.Disabled,
ContainerUUIDs: k.ContainerUUIDs,
LastUsedIP: k.LastUsedIP,
}
}
func generateShortID() string { func generateShortID() string {
b := make([]byte, 4) b := make([]byte, 4)
rand.Read(b) rand.Read(b)
return hex.EncodeToString(b) return hex.EncodeToString(b)
} }
// hashKey creates a simple hash for storage (not reversible) const (
func hashKey(key string) string { apiKeyHashPrefix = "argon2id"
sum := sha256.Sum256([]byte(key)) apiKeyHashTime = uint32(3)
return hex.EncodeToString(sum[:]) apiKeyHashMemory = uint32(64 * 1024)
apiKeyHashThreads = uint8(1)
apiKeyHashSaltLength = 16
apiKeyHashKeyLength = uint32(32)
)
// hashAPIKey stores API keys using a salted slow password-hash style function.
func hashAPIKey(key string) (string, error) {
salt := make([]byte, apiKeyHashSaltLength)
if _, err := rand.Read(salt); err != nil {
return "", err
}
return hashAPIKeyWithSalt(key, salt), nil
}
func hashAPIKeyWithSalt(key string, salt []byte) string {
digest := argon2.IDKey([]byte(key), salt, apiKeyHashTime, apiKeyHashMemory, apiKeyHashThreads, apiKeyHashKeyLength)
return fmt.Sprintf("%s$v=19$m=%d,t=%d,p=%d$%s$%s",
apiKeyHashPrefix,
apiKeyHashMemory,
apiKeyHashTime,
apiKeyHashThreads,
hex.EncodeToString(salt),
hex.EncodeToString(digest),
)
}
func verifyAPIKeyHash(rawKey, storedHash string) bool {
parts := strings.Split(storedHash, "$")
if len(parts) != 5 || parts[0] != apiKeyHashPrefix || parts[1] != "v=19" {
return false
}
var memory, iterations uint32
var threads uint8
if _, err := fmt.Sscanf(parts[2], "m=%d,t=%d,p=%d", &memory, &iterations, &threads); err != nil {
return false
}
if memory != apiKeyHashMemory || iterations != apiKeyHashTime || threads != apiKeyHashThreads {
return false
}
salt, err := hex.DecodeString(parts[3])
if err != nil || len(salt) == 0 {
return false
}
expected, err := hex.DecodeString(parts[4])
if err != nil || len(expected) == 0 {
return false
}
digest := argon2.IDKey([]byte(rawKey), salt, iterations, memory, threads, uint32(len(expected)))
return subtle.ConstantTimeCompare(digest, expected) == 1
} }
func legacyHashKey(key string) string { func legacyHashKey(key string) string {
@@ -128,20 +299,75 @@ func legacyHashKey(key string) string {
return hex.EncodeToString(b) return hex.EncodeToString(b)
} }
// validateApiKey checks if the given key is valid and IP is allowed func matchApiKey(rawKey string) (idx int, needsRehash bool) {
func validateApiKey(rawKey, clientIP string) bool {
hashed := hashKey(rawKey)
legacyHashed := legacyHashKey(rawKey) legacyHashed := legacyHashKey(rawKey)
for _, k := range config.AppConfig.ApiKeys { for i, k := range config.AppConfig.ApiKeys {
if subtle.ConstantTimeCompare([]byte(k.KeyHash), []byte(hashed)) == 1 || if verifyAPIKeyHash(rawKey, k.KeyHash) {
subtle.ConstantTimeCompare([]byte(k.KeyHash), []byte(legacyHashed)) == 1 { return i, false
if k.IPWhitelist == "" { }
return true if subtle.ConstantTimeCompare([]byte(k.KeyHash), []byte(legacyHashed)) == 1 {
} return i, true
return isIPAllowed(clientIP, k.IPWhitelist)
} }
} }
return false return -1, false
}
// validateApiKey checks if the given key is valid and IP is allowed.
func validateApiKey(rawKey, clientIP string) bool {
_, ok := validateApiKeyDetails(rawKey, clientIP)
return ok
}
func validateApiKeyDetails(rawKey, clientIP string) (*config.ApiKeyConfig, bool) {
idx, needsRehash := matchApiKey(rawKey)
if idx < 0 {
return nil, false
}
k := &config.AppConfig.ApiKeys[idx]
if k.Disabled || apiKeyExpired(k.ExpiresAt) {
return nil, false
}
if clientIP != "" && k.IPWhitelist != "" && !isIPAllowed(clientIP, k.IPWhitelist) {
return nil, false
}
if needsRehash {
if newHash, err := hashAPIKey(rawKey); err == nil {
config.AppConfig.ApiKeys[idx].KeyHash = newHash
config.SaveConfig()
}
}
if len(k.Scopes) == 0 {
k.Scopes = []string{"*"}
}
return k, true
}
func validateApiKeyRequest(r *http.Request) (*config.ApiKeyConfig, bool) {
apiKey := apiKeyFromRequest(r)
if apiKey == "" {
return nil, false
}
key, ok := validateApiKeyDetails(apiKey, clientIP(r))
if !ok {
return nil, false
}
updateApiKeyLastUsedForKey(key, clientIP(r))
return key, true
}
func authContextFromAPIKey(key *config.ApiKeyConfig) AuthContext {
actor := "api:" + key.ID
if key.Name != "" {
actor = "api:" + key.Name
}
return AuthContext{
Type: authTypeAPIKey,
ApiKeyID: key.ID,
ApiKeyName: key.Name,
Actor: actor,
Scopes: normalizeApiKeyScopes(key.Scopes),
ContainerUUIDs: key.ContainerUUIDs,
}
} }
func apiKeyFromRequest(r *http.Request) string { func apiKeyFromRequest(r *http.Request) string {
@@ -156,23 +382,16 @@ func apiKeyFromRequest(r *http.Request) string {
} }
func isValidApiKeyRequest(r *http.Request) bool { func isValidApiKeyRequest(r *http.Request) bool {
apiKey := apiKeyFromRequest(r) _, ok := validateApiKeyRequest(r)
if apiKey == "" { return ok
return false
}
if !validateApiKey(apiKey, clientIP(r)) {
return false
}
updateApiKeyLastUsed(apiKey)
return true
} }
// isIPAllowed checks if clientIP matches any entry in the whitelist // isIPAllowed checks if clientIP matches any entry in the whitelist
func isIPAllowed(clientIP, whitelist string) bool { func isIPAllowed(clientIP, whitelist string) bool {
clientIP = strings.TrimSpace(clientIP) clientIP = normalizeIPString(clientIP)
// Strip port if present client := net.ParseIP(clientIP)
if idx := strings.LastIndex(clientIP, ":"); idx > strings.LastIndex(clientIP, "]") { if client == nil {
clientIP = clientIP[:idx] return false
} }
for _, entry := range strings.Split(whitelist, "\n") { for _, entry := range strings.Split(whitelist, "\n") {
entry = strings.TrimSpace(entry) entry = strings.TrimSpace(entry)
@@ -180,77 +399,97 @@ func isIPAllowed(clientIP, whitelist string) bool {
continue continue
} }
if strings.Contains(entry, "/") { if strings.Contains(entry, "/") {
// CIDR match _, network, err := net.ParseCIDR(entry)
if ipInCIDR(clientIP, entry) { if err == nil && network.Contains(client) {
return true return true
} }
} else if entry == clientIP { continue
}
if allowed := net.ParseIP(normalizeIPString(entry)); allowed != nil && allowed.Equal(client) {
return true return true
} }
} }
return false return false
} }
func ipInCIDR(ipStr, cidr string) bool { func normalizeIPString(s string) string {
parts := strings.Split(cidr, "/")
if len(parts) != 2 {
return false
}
// Simple prefix match for IPv4
ip := netParseIP(ipStr)
cidrIP := netParseIP(parts[0])
if ip == nil || cidrIP == nil {
return false
}
bits, err := strconv.Atoi(parts[1])
if err != nil || bits < 0 || bits > 32 {
return false
}
mask := uint32(0xFFFFFFFF) << (32 - bits)
ipVal := ip4ToUint32(ip)
cidrVal := ip4ToUint32(cidrIP)
return (ipVal & mask) == (cidrVal & mask)
}
func netParseIP(s string) net.IP {
s = strings.TrimSpace(s) s = strings.TrimSpace(s)
if idx := strings.LastIndex(s, ":"); idx > strings.LastIndex(s, "]") { if host, _, err := net.SplitHostPort(s); err == nil {
s = s[:idx] return strings.Trim(host, "[]")
} }
return net.ParseIP(s) return strings.Trim(s, "[]")
} }
func ip4ToUint32(ip net.IP) uint32 { func ipInCIDR(ipStr, cidr string) bool {
ip = ip.To4() ip := net.ParseIP(normalizeIPString(ipStr))
if ip == nil { _, network, err := net.ParseCIDR(cidr)
return 0 return err == nil && ip != nil && network.Contains(ip)
}
return uint32(ip[0])<<24 | uint32(ip[1])<<16 | uint32(ip[2])<<8 | uint32(ip[3])
} }
// updateApiKeyLastUsed marks the key as recently used // updateApiKeyLastUsed marks the key as recently used.
func updateApiKeyLastUsed(rawKey string) { func updateApiKeyLastUsed(rawKey string) {
hashed := hashKey(rawKey) key, ok := validateApiKeyDetails(rawKey, "")
now := time.Now().Format("2006-01-02 15:04:05") if !ok {
for i := range config.AppConfig.ApiKeys { return
if config.AppConfig.ApiKeys[i].KeyHash == hashed {
config.AppConfig.ApiKeys[i].LastUsed = now
config.SaveConfig()
return
}
} }
updateApiKeyLastUsedForKey(key, "")
}
func updateApiKeyLastUsedForKey(key *config.ApiKeyConfig, ip string) {
key.LastUsed = time.Now().Format("2006-01-02 15:04:05")
if ip != "" {
key.LastUsedIP = ip
}
config.SaveConfig()
} }
// ApiKeyMiddleware authenticates requests via X-API-Key header or Authorization bearer. // ApiKeyMiddleware authenticates requests via X-API-Key header or Authorization bearer.
func ApiKeyMiddleware(next http.HandlerFunc) http.HandlerFunc { func ApiKeyMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
apiKey := apiKeyFromRequest(r) key, ok := validateApiKeyRequest(r)
if apiKey == "" || !validateApiKey(apiKey, clientIP(r)) { if !ok {
jsonResponse(w, http.StatusUnauthorized, APIResponse{Success: false, Message: "Invalid API key or IP not in whitelist"}) jsonResponse(w, http.StatusUnauthorized, APIResponse{Success: false, Message: "Invalid API key or IP not in whitelist"})
return return
} }
next(w, withAuthContext(r, authContextFromAPIKey(key)))
updateApiKeyLastUsed(apiKey)
next(w, r)
} }
} }
func normalizeApiKeyScopes(scopes []string) []string {
return normalizeRequestedScopes(scopes, []string{"*"})
}
func normalizeRequestedScopes(scopes []string, fallback []string) []string {
result := normalizeStringSlice(scopes)
if len(result) == 0 {
return append([]string(nil), fallback...)
}
return result
}
func normalizeStringSlice(values []string) []string {
seen := map[string]bool{}
result := make([]string, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" || seen[value] {
continue
}
seen[value] = true
result = append(result, value)
}
return result
}
func validApiKeyTime(value string) bool {
_, err := time.ParseInLocation("2006-01-02 15:04:05", value, time.Local)
return err == nil
}
func apiKeyExpired(value string) bool {
if strings.TrimSpace(value) == "" {
return false
}
expiresAt, err := time.ParseInLocation("2006-01-02 15:04:05", value, time.Local)
return err == nil && !time.Now().Before(expiresAt)
}
+102
View File
@@ -0,0 +1,102 @@
package api
import (
"strings"
"testing"
"clicd/internal/config"
)
func TestHashAPIKeyUsesSaltedArgon2idHash(t *testing.T) {
raw := "clicd_sk_0123456789abcdef0123456789abcdef"
h1, err := hashAPIKey(raw)
if err != nil {
t.Fatal(err)
}
h2, err := hashAPIKey(raw)
if err != nil {
t.Fatal(err)
}
if h1 == h2 {
t.Fatal("expected salted hashes to differ")
}
if !strings.HasPrefix(h1, apiKeyHashPrefix+"$") || !strings.HasPrefix(h2, apiKeyHashPrefix+"$") {
t.Fatalf("expected argon2id hashes, got %q and %q", h1, h2)
}
if !verifyAPIKeyHash(raw, h1) || !verifyAPIKeyHash(raw, h2) {
t.Fatal("argon2id hashes did not verify")
}
if verifyAPIKeyHash(raw+"x", h1) {
t.Fatal("argon2id hash verified wrong key")
}
}
func TestValidateApiKeyAllowsArgon2idAndUpdatesLastUsed(t *testing.T) {
raw := "clicd_sk_0123456789abcdef0123456789abcdef"
hash, err := hashAPIKey(raw)
if err != nil {
t.Fatal(err)
}
config.AppConfig = &config.ClicdConfig{
ApiKeys: []config.ApiKeyConfig{{
ID: "key1",
Name: "test",
KeyHash: hash,
}},
}
if !validateApiKey(raw, "127.0.0.1") {
t.Fatal("validateApiKey rejected valid argon2id key")
}
updateApiKeyLastUsed(raw)
if config.AppConfig.ApiKeys[0].LastUsed == "" {
t.Fatal("LastUsed was not updated")
}
}
func TestValidateApiKeyMigratesLegacyHash(t *testing.T) {
raw := "clicd_sk_0123456789abcdef0123456789abcdef"
config.AppConfig = &config.ClicdConfig{
ApiKeys: []config.ApiKeyConfig{{
ID: "legacy",
Name: "legacy",
KeyHash: legacyHashKey(raw),
}},
}
if !validateApiKey(raw, "127.0.0.1") {
t.Fatal("validateApiKey rejected valid legacy key")
}
migrated := config.AppConfig.ApiKeys[0].KeyHash
if migrated == legacyHashKey(raw) {
t.Fatal("legacy key hash was not migrated")
}
if !verifyAPIKeyHash(raw, migrated) {
t.Fatal("migrated key hash does not verify")
}
}
func TestValidateApiKeyAppliesIPWhitelist(t *testing.T) {
raw := "clicd_sk_0123456789abcdef0123456789abcdef"
hash, err := hashAPIKey(raw)
if err != nil {
t.Fatal(err)
}
config.AppConfig = &config.ClicdConfig{
ApiKeys: []config.ApiKeyConfig{{
ID: "key1",
Name: "test",
KeyHash: hash,
IPWhitelist: "192.0.2.10",
}},
}
if validateApiKey(raw, "198.51.100.10") {
t.Fatal("validateApiKey allowed disallowed IP")
}
if !validateApiKey(raw, "192.0.2.10") {
t.Fatal("validateApiKey rejected allowed IP")
}
}
+165 -4
View File
@@ -1,6 +1,7 @@
package api package api
import ( import (
"context"
"encoding/json" "encoding/json"
"net/http" "net/http"
"strings" "strings"
@@ -28,6 +29,132 @@ type APIResponse struct {
Data interface{} `json:"data,omitempty"` Data interface{} `json:"data,omitempty"`
} }
type authContextKey struct{}
type AuthContext struct {
Type string
Username string
ApiKeyID string
ApiKeyName string
Actor string
Scopes []string
ContainerUUIDs []string
}
const (
authTypeAdmin = "admin"
authTypeSubUser = "sub_user"
authTypeAPIKey = "api_key"
)
func withAuthContext(r *http.Request, auth AuthContext) *http.Request {
return r.WithContext(context.WithValue(r.Context(), authContextKey{}, auth))
}
func authContextFromRequest(r *http.Request) (AuthContext, bool) {
ctx, ok := r.Context().Value(authContextKey{}).(AuthContext)
return ctx, ok
}
func requestActor(r *http.Request) string {
if ctx, ok := authContextFromRequest(r); ok && ctx.Actor != "" {
return ctx.Actor
}
if claims, ok := claimsFromRequest(r); ok {
if subUser, _ := claims["sub_user"].(string); subUser != "" {
return "user:" + subUser
}
if username, _ := claims["username"].(string); username != "" {
return username
}
}
return "admin"
}
func hasScope(r *http.Request, scope string) bool {
ctx, ok := authContextFromRequest(r)
if !ok {
return true
}
switch ctx.Type {
case authTypeAdmin:
return true
case authTypeSubUser:
return subUserScopeAllowed(scope)
case authTypeAPIKey:
return scopeAllowed(ctx.Scopes, scope)
default:
return false
}
}
func subUserScopeAllowed(scope string) bool {
switch scope {
case "container:read", "container:power", "container:reinstall", "container:network",
"dashboard:read", "image:read", "task:read", "snapshot:read", "snapshot:create", "snapshot:delete", "snapshot:restore", "snapshot:schedule",
"terminal:ssh", "terminal:vnc":
return true
default:
return false
}
}
func hasAnyScope(r *http.Request, scopes ...string) bool {
for _, scope := range scopes {
if hasScope(r, scope) {
return true
}
}
return false
}
func scopeAllowed(scopes []string, required string) bool {
for _, scope := range scopes {
scope = strings.TrimSpace(scope)
if scope == "*" || scope == "admin:*" || scope == required {
return true
}
if strings.HasSuffix(scope, ":*") {
prefix := strings.TrimSuffix(scope, "*")
if strings.HasPrefix(required, prefix) {
return true
}
}
}
return false
}
func requireScope(w http.ResponseWriter, r *http.Request, scope string) bool {
if hasScope(r, scope) {
return true
}
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Insufficient API key scope"})
return false
}
func ScopeMiddleware(scope string, next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !requireScope(w, r, scope) {
return
}
next(w, r)
}
}
func AnyScopeMiddleware(scopes []string, next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if hasAnyScope(r, scopes...) {
next(w, r)
return
}
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Insufficient API key scope"})
}
}
func auditRequest(r *http.Request, action, target, detail string, success bool, errMsg string) {
config.AddAuditLogFull(action, target, detail, requestActor(r), clientIP(r), r.UserAgent(), success, errMsg)
}
func jsonResponse(w http.ResponseWriter, status int, resp APIResponse) { func jsonResponse(w http.ResponseWriter, status int, resp APIResponse) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status) w.WriteHeader(status)
@@ -75,8 +202,10 @@ func claimsFromToken(tokenString string) (jwt.MapClaims, bool) {
if subUser, _ := claims["sub_user"].(string); subUser != "" { if subUser, _ := claims["sub_user"].(string); subUser != "" {
tokenVersionFloat, hasVersion := claims["token_version"].(float64) tokenVersionFloat, hasVersion := claims["token_version"].(float64)
tokenVersion := int(tokenVersionFloat) tokenVersion := int(tokenVersionFloat)
foundSubUser := false
for i := range config.AppConfig.SubUsers { for i := range config.AppConfig.SubUsers {
if config.AppConfig.SubUsers[i].Username == subUser { if config.AppConfig.SubUsers[i].Username == subUser {
foundSubUser = true
stored := config.AppConfig.SubUsers[i].TokenVersion stored := config.AppConfig.SubUsers[i].TokenVersion
// If stored version > 0, require token_version to match exactly. // If stored version > 0, require token_version to match exactly.
// This also rejects legacy tokens that lack token_version entirely. // This also rejects legacy tokens that lack token_version entirely.
@@ -86,6 +215,9 @@ func claimsFromToken(tokenString string) (jwt.MapClaims, bool) {
break break
} }
} }
if !foundSubUser {
return nil, false
}
} }
return claims, ok return claims, ok
@@ -96,6 +228,9 @@ func claimsFromRequest(r *http.Request) (jwt.MapClaims, bool) {
} }
func isSubUserRequest(r *http.Request) bool { func isSubUserRequest(r *http.Request) bool {
if ctx, ok := authContextFromRequest(r); ok {
return ctx.Type == authTypeSubUser
}
claims, ok := claimsFromRequest(r) claims, ok := claimsFromRequest(r)
if !ok { if !ok {
return false return false
@@ -210,19 +345,45 @@ func HandleCheckAuth(w http.ResponseWriter, r *http.Request) {
func AuthMiddleware(next http.HandlerFunc) http.HandlerFunc { func AuthMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
tokenString := tokenFromRequest(r) tokenString := tokenFromRequest(r)
if !isValidToken(tokenString) && !isValidApiKeyRequest(r) { if claims, ok := claimsFromToken(tokenString); ok {
jsonResponse(w, http.StatusUnauthorized, APIResponse{Success: false, Message: "Authentication required"}) if subUser, _ := claims["sub_user"].(string); subUser != "" {
auth := AuthContext{Type: authTypeSubUser, Username: subUser, Actor: "user:" + subUser}
if values, ok := claims["container_uuids"].([]interface{}); ok {
for _, value := range values {
if uuid, ok := value.(string); ok {
auth.ContainerUUIDs = append(auth.ContainerUUIDs, uuid)
}
}
}
next(w, withAuthContext(r, auth))
return
}
username, _ := claims["username"].(string)
if username == "" {
username = config.AppConfig.AdminUser
}
next(w, withAuthContext(r, AuthContext{Type: authTypeAdmin, Username: username, Actor: username}))
return return
} }
next(w, r) if key, ok := validateApiKeyRequest(r); ok {
next(w, withAuthContext(r, authContextFromAPIKey(key)))
return
}
jsonResponse(w, http.StatusUnauthorized, APIResponse{Success: false, Message: "Authentication required"})
} }
} }
// AdminMiddleware requires a valid administrator token and rejects sub-user tokens. // AdminMiddleware requires a valid administrator token and rejects sub-user tokens.
func AdminMiddleware(next http.HandlerFunc) http.HandlerFunc { func AdminMiddleware(next http.HandlerFunc) http.HandlerFunc {
return AuthMiddleware(func(w http.ResponseWriter, r *http.Request) { return AuthMiddleware(func(w http.ResponseWriter, r *http.Request) {
if isSubUserRequest(r) { ctx, _ := authContextFromRequest(r)
if ctx.Type == authTypeSubUser {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Administrator permission required"})
return
}
if ctx.Type == authTypeAPIKey && !scopeAllowed(ctx.Scopes, "admin:access") {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Administrator permission required"}) jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Administrator permission required"})
return return
} }
@@ -0,0 +1,7 @@
//go:build !linux
package api
func getRootDiskInfo() (DiskInfo, bool) {
return DiskInfo{}, false
}
+21
View File
@@ -0,0 +1,21 @@
//go:build linux
package api
import "golang.org/x/sys/unix"
func getRootDiskInfo() (DiskInfo, bool) {
var stat unix.Statfs_t
if err := unix.Statfs("/", &stat); err != nil {
return DiskInfo{}, false
}
total := float64(int64(stat.Blocks)*int64(stat.Bsize)) / (1024 * 1024 * 1024)
free := float64(int64(stat.Bavail)*int64(stat.Bsize)) / (1024 * 1024 * 1024)
return DiskInfo{
TotalGB: total,
UsedGB: total - free,
FreeGB: free,
}, true
}
+142 -2
View File
@@ -2,10 +2,12 @@ package api
import ( import (
"encoding/json" "encoding/json"
"fmt"
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
"time" "time"
"unicode"
"clicd/internal/config" "clicd/internal/config"
"clicd/internal/lxc" "clicd/internal/lxc"
@@ -18,17 +20,41 @@ var lxcManager = lxc.NewManager()
func HandleContainers(w http.ResponseWriter, r *http.Request) { func HandleContainers(w http.ResponseWriter, r *http.Request) {
switch r.Method { switch r.Method {
case http.MethodGet: case http.MethodGet:
if !requireScope(w, r, "container:read") {
return
}
listContainers(w, r) listContainers(w, r)
case http.MethodPost: case http.MethodPost:
if !requireScope(w, r, "container:create") {
return
}
if isAccessRestrictedRequest(r) {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Container-bound API keys cannot create containers"})
return
}
createContainer(w, r) createContainer(w, r)
default: default:
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
} }
} }
// HandleContainerListAlias supports legacy integrations that call
// /api/containers/list or /api/v1/containers/list.
func HandleContainerListAlias(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodPost {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
if !requireScope(w, r, "container:read") {
return
}
listContainers(w, r)
}
// HandleSingleContainer handles individual container operations by ID or name: /api/containers/{id-or-name}/... // HandleSingleContainer handles individual container operations by ID or name: /api/containers/{id-or-name}/...
func HandleSingleContainer(w http.ResponseWriter, r *http.Request) { func HandleSingleContainer(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/api/containers/") path := strings.TrimPrefix(r.URL.Path, "/api/v1/containers/")
path = strings.TrimPrefix(path, "/api/containers/")
parts := strings.SplitN(path, "/", 2) parts := strings.SplitN(path, "/", 2)
c := containerByIdentifier(parts[0]) c := containerByIdentifier(parts[0])
id := 0 id := 0
@@ -48,6 +74,10 @@ func HandleSingleContainer(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"}) jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"})
return return
} }
if !isSnapshotAction && !isContainerAllowedForRequest(r, parts[0]) {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Access denied to this container"})
return
}
if isSnapshotAction && id == 0 { if isSnapshotAction && id == 0 {
// For orphaned snapshots, resolve containerID from the snapshot itself // For orphaned snapshots, resolve containerID from the snapshot itself
snapshotID := strings.TrimPrefix(action, "snapshots/") snapshotID := strings.TrimPrefix(action, "snapshots/")
@@ -59,45 +89,105 @@ func HandleSingleContainer(w http.ResponseWriter, r *http.Request) {
} }
id = snapshot.ContainerID id = snapshot.ContainerID
} }
if isSnapshotAction {
if c := config.FindContainer(id); c != nil && !isContainerAllowedForRequest(r, c.UUID) {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Access denied to this container"})
return
}
}
switch { switch {
case action == "start" && r.Method == http.MethodPost: case action == "start" && r.Method == http.MethodPost:
if !requireScope(w, r, "container:power") {
return
}
HandleSingleTaskAction(w, r, id, "start") HandleSingleTaskAction(w, r, id, "start")
case action == "stop" && r.Method == http.MethodPost: case action == "stop" && r.Method == http.MethodPost:
if !requireScope(w, r, "container:power") {
return
}
HandleSingleTaskAction(w, r, id, "stop") HandleSingleTaskAction(w, r, id, "stop")
case action == "restart" && r.Method == http.MethodPost: case action == "restart" && r.Method == http.MethodPost:
if !requireScope(w, r, "container:power") {
return
}
HandleSingleTaskAction(w, r, id, "restart") HandleSingleTaskAction(w, r, id, "restart")
case action == "reinstall" && r.Method == http.MethodPost: case action == "reinstall" && r.Method == http.MethodPost:
if !requireScope(w, r, "container:reinstall") {
return
}
HandleSingleTaskAction(w, r, id, "reinstall") HandleSingleTaskAction(w, r, id, "reinstall")
case action == "delete" && r.Method == http.MethodDelete: case action == "delete" && r.Method == http.MethodDelete:
if !requireScope(w, r, "container:delete") {
return
}
HandleSingleTaskAction(w, r, id, "delete") HandleSingleTaskAction(w, r, id, "delete")
case action == "reset-password" && r.Method == http.MethodPost: case action == "reset-password" && r.Method == http.MethodPost:
if !requireScope(w, r, "container:password") {
return
}
resetSSHPassword(w, r, id) resetSSHPassword(w, r, id)
case action == "usage" && r.Method == http.MethodGet: case action == "usage" && r.Method == http.MethodGet:
if !requireScope(w, r, "container:read") {
return
}
getUsage(w, r, id) getUsage(w, r, id)
case action == "traffic" && r.Method == http.MethodGet: case action == "traffic" && r.Method == http.MethodGet:
if !requireScope(w, r, "container:read") {
return
}
getTraffic(w, r, id) getTraffic(w, r, id)
case action == "traffic-reset" && r.Method == http.MethodPost: case action == "traffic-reset" && r.Method == http.MethodPost:
if !requireScope(w, r, "container:traffic") {
return
}
resetTraffic(w, r, id) resetTraffic(w, r, id)
case action == "traffic-limit" && r.Method == http.MethodPut: case action == "traffic-limit" && r.Method == http.MethodPut:
if !requireScope(w, r, "container:traffic") {
return
}
updateTrafficLimit(w, r, id) updateTrafficLimit(w, r, id)
case action == "resource-limit" && r.Method == http.MethodPut: case action == "resource-limit" && r.Method == http.MethodPut:
if !requireScope(w, r, "container:resize") {
return
}
updateResourceLimit(w, r, id) updateResourceLimit(w, r, id)
case action == "random-port" && r.Method == http.MethodGet: case action == "random-port" && r.Method == http.MethodGet:
if !requireScope(w, r, "container:network") {
return
}
getRandomPort(w, r, id) getRandomPort(w, r, id)
case action == "expiry" && r.Method == http.MethodPut: case action == "expiry" && r.Method == http.MethodPut:
if !requireScope(w, r, "container:resize") {
return
}
updateExpiry(w, r, id) updateExpiry(w, r, id)
case action == "ipv6" && r.Method == http.MethodPost: case action == "ipv6" && r.Method == http.MethodPost:
if !requireScope(w, r, "ipv6:assign") {
return
}
assignIPv6(w, r, id) assignIPv6(w, r, id)
case action == "snapshots" || strings.HasPrefix(action, "snapshots/"): case action == "snapshots" || strings.HasPrefix(action, "snapshots/"):
handleContainerSnapshots(w, r, id, action) handleContainerSnapshots(w, r, id, action)
case action == "port-mappings" && r.Method == http.MethodPost: case action == "port-mappings" && r.Method == http.MethodPost:
if !requireScope(w, r, "container:network") {
return
}
addPortMapping(w, r, id) addPortMapping(w, r, id)
case strings.HasPrefix(action, "port-mappings/") && r.Method == http.MethodPut: case strings.HasPrefix(action, "port-mappings/") && r.Method == http.MethodPut:
if !requireScope(w, r, "container:network") {
return
}
updatePortMapping(w, r, id, strings.TrimPrefix(action, "port-mappings/")) updatePortMapping(w, r, id, strings.TrimPrefix(action, "port-mappings/"))
case strings.HasPrefix(action, "port-mappings/") && r.Method == http.MethodDelete: case strings.HasPrefix(action, "port-mappings/") && r.Method == http.MethodDelete:
if !requireScope(w, r, "container:network") {
return
}
deletePortMapping(w, r, id, strings.TrimPrefix(action, "port-mappings/")) deletePortMapping(w, r, id, strings.TrimPrefix(action, "port-mappings/"))
case r.Method == http.MethodGet: case r.Method == http.MethodGet:
if !requireScope(w, r, "container:read") {
return
}
getContainer(w, r, id) getContainer(w, r, id)
default: default:
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Action not found"}) jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Action not found"})
@@ -346,6 +436,9 @@ func HandleTemplates(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return return
} }
if !requireScope(w, r, "image:read") {
return
}
if isSubUserRequest(r) { if isSubUserRequest(r) {
HandleEnabledImages(w, r) HandleEnabledImages(w, r)
return return
@@ -360,7 +453,11 @@ func HandleDashboard(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return return
} }
if !requireScope(w, r, "dashboard:read") {
return
}
containers, _ := listByRuntime() containers, _ := listByRuntime()
containers = filterContainersForRequest(r, containers)
running := 0 running := 0
stopped := 0 stopped := 0
for _, c := range containers { for _, c := range containers {
@@ -384,6 +481,9 @@ func HandleHostInfo(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return return
} }
if !requireScope(w, r, "host:read") {
return
}
info := getHostInfo() info := getHostInfo()
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: info}) jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: info})
} }
@@ -394,7 +494,24 @@ func resetSSHPassword(w http.ResponseWriter, r *http.Request, id int) {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "容器已到期,不允许此操作"}) jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "容器已到期,不允许此操作"})
return return
} }
newPassword, err := resetPasswordByRuntime(id) var req struct {
Password string `json:"password"`
}
if r.Body != nil {
decoder := json.NewDecoder(r.Body)
if err := decoder.Decode(&req); err != nil && err.Error() != "EOF" {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
return
}
}
password := strings.TrimSpace(req.Password)
if password != "" {
if err := validateSSHPassword(password); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
return
}
}
newPassword, err := resetPasswordByRuntime(id, password)
if err != nil { if err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()}) jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
return return
@@ -406,6 +523,29 @@ func resetSSHPassword(w http.ResponseWriter, r *http.Request, id int) {
}) })
} }
func validateSSHPassword(password string) error {
if len(password) < 8 || len(password) > 64 {
return fmt.Errorf("密码长度必须为 8-64 位")
}
hasLetter := false
hasDigit := false
for _, r := range password {
if unicode.IsSpace(r) {
return fmt.Errorf("密码不能包含空白字符")
}
if unicode.IsLetter(r) {
hasLetter = true
}
if unicode.IsDigit(r) {
hasDigit = true
}
}
if !hasLetter || !hasDigit {
return fmt.Errorf("密码至少需要包含字母和数字")
}
return nil
}
func addPortMapping(w http.ResponseWriter, r *http.Request, id int) { func addPortMapping(w http.ResponseWriter, r *http.Request, id int) {
var pm config.PortMapping var pm config.PortMapping
if err := json.NewDecoder(r.Body).Decode(&pm); err != nil { if err := json.NewDecoder(r.Body).Decode(&pm); err != nil {
File diff suppressed because it is too large Load Diff
+294 -92
View File
@@ -1,6 +1,7 @@
package api package api
import ( import (
"context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
@@ -8,6 +9,7 @@ import (
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"sync" "sync"
"time"
"clicd/internal/config" "clicd/internal/config"
"clicd/internal/kvm" "clicd/internal/kvm"
@@ -16,22 +18,143 @@ import (
// ImageInfo represents a template image with its download/enable status. // ImageInfo represents a template image with its download/enable status.
type ImageInfo struct { type ImageInfo struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Type string `json:"type"` Type string `json:"type"`
Distro string `json:"distro"` Distro string `json:"distro"`
Release string `json:"release"` Release string `json:"release"`
Arch string `json:"arch"` Arch string `json:"arch"`
Description string `json:"description"` Description string `json:"description"`
Downloaded bool `json:"downloaded"` Downloaded bool `json:"downloaded"`
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
Downloading bool `json:"downloading"` Downloading bool `json:"downloading"`
SizeBytes int64 `json:"size_bytes"` Progress int `json:"progress"`
ManualPath string `json:"manual_path,omitempty"` DownloadedBytes int64 `json:"downloaded_bytes"`
TotalBytes int64 `json:"total_bytes"`
Stage string `json:"stage,omitempty"`
Error string `json:"error,omitempty"`
SizeBytes int64 `json:"size_bytes"`
ManualPath string `json:"manual_path,omitempty"`
Desktop string `json:"desktop,omitempty"`
} }
var imageDownloadsMu sync.Mutex var imageDownloadsMu sync.Mutex
var imageDownloads = map[string]bool{} var imageDownloads = map[string]*imageDownloadStatus{}
type imageDownloadStatus struct {
Downloading bool
Progress int
DownloadedBytes int64
TotalBytes int64
Stage string
Error string
Cancel context.CancelFunc
UpdatedAt time.Time
}
type imageDownloadSnapshot struct {
Downloading bool
Progress int
DownloadedBytes int64
TotalBytes int64
Stage string
Error string
}
func imageDownloadInfo(id string) imageDownloadSnapshot {
imageDownloadsMu.Lock()
defer imageDownloadsMu.Unlock()
st := imageDownloads[id]
if st == nil {
return imageDownloadSnapshot{}
}
return imageDownloadSnapshot{
Downloading: st.Downloading,
Progress: st.Progress,
DownloadedBytes: st.DownloadedBytes,
TotalBytes: st.TotalBytes,
Stage: st.Stage,
Error: st.Error,
}
}
func startImageDownload(id, stage string) (context.Context, bool) {
imageDownloadsMu.Lock()
defer imageDownloadsMu.Unlock()
if st := imageDownloads[id]; st != nil && st.Downloading {
return nil, false
}
ctx, cancel := context.WithCancel(context.Background())
imageDownloads[id] = &imageDownloadStatus{
Downloading: true,
Stage: stage,
Cancel: cancel,
UpdatedAt: time.Now(),
}
return ctx, true
}
func updateImageDownload(id string, update func(*imageDownloadStatus)) {
imageDownloadsMu.Lock()
defer imageDownloadsMu.Unlock()
st := imageDownloads[id]
if st == nil {
return
}
update(st)
st.UpdatedAt = time.Now()
}
func finishImageDownload(id string, err error) {
imageDownloadsMu.Lock()
defer imageDownloadsMu.Unlock()
st := imageDownloads[id]
if st == nil {
return
}
st.Downloading = false
st.Cancel = nil
st.UpdatedAt = time.Now()
if err != nil {
st.Error = err.Error()
return
}
delete(imageDownloads, id)
}
func clearImageDownload(id string) {
imageDownloadsMu.Lock()
delete(imageDownloads, id)
imageDownloadsMu.Unlock()
}
func isImageDownloadActive(id string) bool {
imageDownloadsMu.Lock()
defer imageDownloadsMu.Unlock()
st := imageDownloads[id]
return st != nil && st.Downloading
}
func lxcImageDownloadTempName(id string) string {
return fmt.Sprintf("clicd-img-dl-%s", id)
}
func cleanupLXCImageDownloadTemp(id string) {
tmpName := lxcImageDownloadTempName(id)
exec.Command("lxc-destroy", "-n", tmpName, "-f").Run()
os.RemoveAll(filepath.Join("/var/lib/lxc", tmpName))
}
func cleanupOldImageDownloadErrors() {
imageDownloadsMu.Lock()
defer imageDownloadsMu.Unlock()
cutoff := time.Now().Add(-10 * time.Minute)
for id, st := range imageDownloads {
if !st.Downloading && st.UpdatedAt.Before(cutoff) {
delete(imageDownloads, id)
}
}
}
// isImageDownloaded checks if the LXC download cache exists for a template. // isImageDownloaded checks if the LXC download cache exists for a template.
func isImageDownloaded(distro, release, arch string) bool { func isImageDownloaded(distro, release, arch string) bool {
@@ -98,60 +221,78 @@ func HandleImages(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return return
} }
if !requireScope(w, r, "image:read") {
return
}
enabledSet := getEnabledImageSet() enabledSet := getEnabledImageSet()
cleanupOldImageDownloadErrors()
templates := lxc.GetTemplates() templates := lxc.GetTemplates()
images := make([]ImageInfo, 0, len(templates)+len(kvm.GetImages())) images := make([]ImageInfo, 0, len(templates)+len(kvm.GetImages()))
for _, t := range templates { for _, t := range templates {
_, downloading := imageDownloads[t.ID] dl := imageDownloadInfo(t.ID)
downloaded, size := imageDownloadedInfo(t.Distro, t.Release, t.Arch) downloaded, size := imageDownloadedInfo(t.Distro, t.Release, t.Arch)
images = append(images, ImageInfo{ images = append(images, ImageInfo{
ID: t.ID, ID: t.ID,
Name: t.Name, Name: t.Name,
Type: config.VirtualizationLXC, Type: config.VirtualizationLXC,
Distro: t.Distro, Distro: t.Distro,
Release: t.Release, Release: t.Release,
Arch: t.Arch, Arch: t.Arch,
Description: t.Description, Description: t.Description,
Downloaded: downloaded, Downloaded: downloaded,
Enabled: enabledSet[t.ID], Enabled: enabledSet[t.ID],
Downloading: downloading, Downloading: dl.Downloading,
SizeBytes: size, Progress: dl.Progress,
DownloadedBytes: dl.DownloadedBytes,
TotalBytes: dl.TotalBytes,
Stage: dl.Stage,
Error: dl.Error,
SizeBytes: size,
}) })
} }
for _, t := range kvm.GetImages() { for _, t := range kvm.GetImages() {
_, downloading := imageDownloads[t.ID] dl := imageDownloadInfo(t.ID)
downloaded, size := kvm.ImageDownloadedInfo(t.ID) downloaded, size := kvm.ImageDownloadedInfo(t.ID)
manualPath := "" manualPath := ""
if t.Distro == "windows" { if t.Distro == "windows" {
manualPath = kvm.ImagePath(t.ID) manualPath = kvm.ImagePath(t.ID)
} }
images = append(images, ImageInfo{ images = append(images, ImageInfo{
ID: t.ID, ID: t.ID,
Name: t.Name, Name: t.Name,
Type: config.VirtualizationKVM, Type: config.VirtualizationKVM,
Distro: t.Distro, Distro: t.Distro,
Release: t.Release, Release: t.Release,
Arch: t.Arch, Arch: t.Arch,
Description: t.Description, Description: t.Description,
Downloaded: downloaded, Downloaded: downloaded,
Enabled: enabledSet[t.ID], Enabled: enabledSet[t.ID],
Downloading: downloading, Downloading: dl.Downloading,
SizeBytes: size, Progress: dl.Progress,
ManualPath: manualPath, DownloadedBytes: dl.DownloadedBytes,
TotalBytes: dl.TotalBytes,
Stage: dl.Stage,
Error: dl.Error,
SizeBytes: size,
ManualPath: manualPath,
Desktop: t.Desktop,
}) })
} }
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: images}) jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: images})
} }
// HandleImageDownload downloads a template image from the LXC image server. // HandleImageDownload starts a template image download in the background.
func HandleImageDownload(w http.ResponseWriter, r *http.Request) { func HandleImageDownload(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return return
} }
if !requireScope(w, r, "image:download") {
return
}
var req struct { var req struct {
TemplateID string `json:"template_id"` TemplateID string `json:"template_id"`
@@ -170,82 +311,130 @@ func HandleImageDownload(w http.ResponseWriter, r *http.Request) {
} }
if ok, _ := kvm.ImageDownloadedInfo(image.ID); ok { if ok, _ := kvm.ImageDownloadedInfo(image.ID); ok {
ensureImageEnabled(image.ID) ensureImageEnabled(image.ID)
clearImageDownload(image.ID)
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Already downloaded"}) jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Already downloaded"})
return return
} }
imageDownloadsMu.Lock() ctx, ok := startImageDownload(image.ID, "downloading")
if imageDownloads[req.TemplateID] { if !ok {
imageDownloadsMu.Unlock()
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: "Already downloading"}) jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: "Already downloading"})
return return
} }
imageDownloads[req.TemplateID] = true go func(image kvm.Image) {
imageDownloadsMu.Unlock() err := kvm.DownloadImageWithProgress(ctx, image, func(p kvm.DownloadProgress) {
defer func() { updateImageDownload(image.ID, func(st *imageDownloadStatus) {
imageDownloadsMu.Lock() if p.Stage != "" {
delete(imageDownloads, req.TemplateID) st.Stage = p.Stage
imageDownloadsMu.Unlock() }
}() if p.DownloadedBytes > 0 || p.TotalBytes > 0 {
ensureImageEnabled(image.ID) st.DownloadedBytes = p.DownloadedBytes
if err := kvm.DownloadImage(*image); err != nil { st.TotalBytes = p.TotalBytes
message := "Download failed: " + err.Error() }
st.Progress = p.Percent
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: message}) })
return })
} if err != nil {
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Downloaded successfully"}) if ctx.Err() != nil {
os.Remove(kvm.ImagePath(image.ID) + ".tmp")
os.Remove(kvm.ImagePath(image.ID))
finishImageDownload(image.ID, nil)
return
}
finishImageDownload(image.ID, err)
return
}
ensureImageEnabled(image.ID)
finishImageDownload(image.ID, nil)
}(*image)
jsonResponse(w, http.StatusAccepted, APIResponse{Success: true, Message: "Download started"})
return return
} }
// Already downloaded? Just enable if needed. // Already downloaded? Just enable if needed.
if isImageDownloaded(tmpl.Distro, tmpl.Release, tmpl.Arch) { if isImageDownloaded(tmpl.Distro, tmpl.Release, tmpl.Arch) {
ensureImageEnabled(tmpl.ID) ensureImageEnabled(tmpl.ID)
clearImageDownload(tmpl.ID)
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Already downloaded"}) jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Already downloaded"})
return return
} }
// Already downloading? ctx, ok := startImageDownload(tmpl.ID, "lxc-create")
imageDownloadsMu.Lock() if !ok {
if imageDownloads[req.TemplateID] {
imageDownloadsMu.Unlock()
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: "Already downloading"}) jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: "Already downloading"})
return return
} }
imageDownloads[req.TemplateID] = true
imageDownloadsMu.Unlock()
defer func() { go func(tmpl lxc.Template) {
imageDownloadsMu.Lock() // Download via lxc-create with a temp container, then destroy it.
delete(imageDownloads, req.TemplateID) tmpName := lxcImageDownloadTempName(tmpl.ID)
imageDownloadsMu.Unlock() args := []string{"-n", tmpName, "-t", "download", "--",
}() "-d", tmpl.Distro, "-r", tmpl.Release, "-a", tmpl.Arch}
if tmpl.Variant != "" {
// Auto-enable on download args = append(args, "--variant", tmpl.Variant)
ensureImageEnabled(tmpl.ID) }
updateImageDownload(tmpl.ID, func(st *imageDownloadStatus) {
// Download via lxc-create with a temp container, then destroy it. st.Stage = "lxc-create"
tmpName := fmt.Sprintf("clicd-img-dl-%s", tmpl.ID)
args := []string{"-n", tmpName, "-t", "download", "--",
"-d", tmpl.Distro, "-r", tmpl.Release, "-a", tmpl.Arch}
if tmpl.Variant != "" {
args = append(args, "--variant", tmpl.Variant)
}
cmd := exec.Command("lxc-create", args...)
output, err := cmd.CombinedOutput()
// Clean up the temp container unconditionally.
exec.Command("lxc-destroy", "-n", tmpName, "-f").Run()
os.RemoveAll(filepath.Join("/var/lib/lxc", tmpName))
if err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{
Success: false,
Message: fmt.Sprintf("Download failed: %v, output: %s", err, string(output)),
}) })
cmd := exec.CommandContext(ctx, "lxc-create", args...)
output, err := cmd.CombinedOutput()
// Clean up the temp container unconditionally.
cleanupLXCImageDownloadTemp(tmpl.ID)
if err != nil {
if ctx.Err() != nil {
finishImageDownload(tmpl.ID, nil)
return
}
err = fmt.Errorf("Download failed: %v, output: %s", err, string(output))
finishImageDownload(tmpl.ID, err)
return
}
ensureImageEnabled(tmpl.ID)
finishImageDownload(tmpl.ID, nil)
}(*tmpl)
jsonResponse(w, http.StatusAccepted, APIResponse{Success: true, Message: "Download started"})
}
// HandleImageCancel cancels an in-progress image download.
func HandleImageCancel(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
if !requireScope(w, r, "image:download") {
return
}
var req struct {
TemplateID string `json:"template_id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.TemplateID == "" {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "template_id required"})
return return
} }
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Downloaded successfully"}) imageDownloadsMu.Lock()
st := imageDownloads[req.TemplateID]
if st == nil || !st.Downloading || st.Cancel == nil {
imageDownloadsMu.Unlock()
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "No active download"})
return
}
cancel := st.Cancel
st.Stage = "canceling"
st.UpdatedAt = time.Now()
imageDownloadsMu.Unlock()
cancel()
if image := kvm.FindImage(req.TemplateID); image != nil {
os.Remove(kvm.ImagePath(image.ID) + ".tmp")
os.Remove(kvm.ImagePath(image.ID))
}
if tmpl := lxc.FindTemplate(req.TemplateID); tmpl != nil {
go cleanupLXCImageDownloadTemp(tmpl.ID)
}
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Cancel requested"})
} }
// HandleImageDelete deletes a cached template image from disk. // HandleImageDelete deletes a cached template image from disk.
@@ -254,6 +443,9 @@ func HandleImageDelete(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return return
} }
if !requireScope(w, r, "image:delete") {
return
}
var req struct { var req struct {
TemplateID string `json:"template_id"` TemplateID string `json:"template_id"`
@@ -262,6 +454,10 @@ func HandleImageDelete(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "template_id required"}) jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "template_id required"})
return return
} }
if isImageDownloadActive(req.TemplateID) {
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: "Image is downloading; cancel it before deleting"})
return
}
tmpl := lxc.FindTemplate(req.TemplateID) tmpl := lxc.FindTemplate(req.TemplateID)
if tmpl == nil { if tmpl == nil {
@@ -300,6 +496,9 @@ func HandleImageToggle(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return return
} }
if !requireScope(w, r, "image:toggle") {
return
}
var req struct { var req struct {
TemplateID string `json:"template_id"` TemplateID string `json:"template_id"`
@@ -326,6 +525,9 @@ func HandleEnabledImages(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return return
} }
if !requireScope(w, r, "image:read") {
return
}
runtime := runtimeFromRequest(r.URL.Query().Get("type")) runtime := runtimeFromRequest(r.URL.Query().Get("type"))
enabledSet := getEnabledImageSet() enabledSet := getEnabledImageSet()
@@ -336,7 +538,7 @@ func HandleEnabledImages(w http.ResponseWriter, r *http.Request) {
if downloaded, _ := kvm.ImageDownloadedInfo(t.ID); enabledSet[t.ID] && downloaded { if downloaded, _ := kvm.ImageDownloadedInfo(t.ID); enabledSet[t.ID] && downloaded {
result = append(result, map[string]string{ result = append(result, map[string]string{
"id": t.ID, "name": t.Name, "distro": t.Distro, "release": t.Release, "arch": t.Arch, "id": t.ID, "name": t.Name, "distro": t.Distro, "release": t.Release, "arch": t.Arch,
"description": t.Description, "type": config.VirtualizationKVM, "description": t.Description, "type": config.VirtualizationKVM, "desktop": t.Desktop,
}) })
} }
} }
+3
View File
@@ -7,6 +7,9 @@ func HandleIPv6Status(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return return
} }
if !requireScope(w, r, "ipv6:read") {
return
}
status := lxcManager.DetectIPv6Status() status := lxcManager.DetectIPv6Status()
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: status}) jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: status})
} }
+3
View File
@@ -50,6 +50,9 @@ func HandleRouting(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return return
} }
if !requireScope(w, r, "routing:read") {
return
}
nat4Mappings := make([]nat4Route, 0) nat4Mappings := make([]nat4Route, 0)
usedPorts := map[int]bool{} usedPorts := map[int]bool{}
+3 -3
View File
@@ -72,12 +72,12 @@ func reinstallByRuntime(id int, templateID string) error {
return lxcManager.ReinstallContainer(id, templateID) return lxcManager.ReinstallContainer(id, templateID)
} }
func resetPasswordByRuntime(id int) (string, error) { func resetPasswordByRuntime(id int, password string) (string, error) {
c := config.FindContainer(id) c := config.FindContainer(id)
if c != nil && c.IsKVM() { if c != nil && c.IsKVM() {
return kvmManager.ResetSSHPassword(id) return kvmManager.ResetSSHPassword(id, password)
} }
return lxcManager.ResetSSHPassword(id) return lxcManager.ResetSSHPassword(id, password)
} }
func assignIPv6ByRuntime(id int) (*config.Container, error) { func assignIPv6ByRuntime(id int) (*config.Container, error) {
+215 -18
View File
@@ -7,6 +7,7 @@ import (
"net/http" "net/http"
"os" "os"
"os/exec" "os/exec"
"sort"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
@@ -557,10 +558,10 @@ func countPorts(totalCounts map[int]int, destCounts map[int]map[string]int, port
func (ss *SecurityScanner) addAlert(name, alertType, severity, srcIP, dstIP string, port int, detail, logLine string) { func (ss *SecurityScanner) addAlert(name, alertType, severity, srcIP, dstIP string, port int, detail, logLine string) {
ss.mu.Lock() ss.mu.Lock()
defer ss.mu.Unlock()
now := time.Now() now := time.Now()
cutoff := now.Add(-5 * time.Minute) cutoff := now.Add(-5 * time.Minute)
shouldShutdown := false
for i := range ss.alerts { for i := range ss.alerts {
a := &ss.alerts[i] a := &ss.alerts[i]
@@ -579,6 +580,11 @@ func (ss *SecurityScanner) addAlert(name, alertType, severity, srcIP, dstIP stri
if severityRank(severity) > severityRank(a.Severity) { if severityRank(severity) > severityRank(a.Severity) {
a.Severity = severity a.Severity = severity
} }
shouldShutdown = config.AppConfig.SecurityAutoShutdown
ss.mu.Unlock()
if shouldShutdown {
autoShutdownAlertContainer(name, alertType, severity)
}
return return
} }
@@ -599,10 +605,16 @@ func (ss *SecurityScanner) addAlert(name, alertType, severity, srcIP, dstIP stri
ss.alerts = append(ss.alerts, alert) ss.alerts = append(ss.alerts, alert)
config.AddAuditLog("security_"+alertType, name, fmt.Sprintf("[%s] %s", severity, detail), "system") config.AddAuditLog("security_"+alertType, name, fmt.Sprintf("[%s] %s", severity, detail), "system")
shouldShutdown = config.AppConfig.SecurityAutoShutdown
if len(ss.alerts) > 200 { if len(ss.alerts) > 200 {
ss.alerts = ss.alerts[len(ss.alerts)-200:] ss.alerts = ss.alerts[len(ss.alerts)-200:]
} }
ss.mu.Unlock()
if shouldShutdown {
autoShutdownAlertContainer(name, alertType, severity)
}
} }
func severityRank(severity string) int { func severityRank(severity string) int {
@@ -620,26 +632,68 @@ func severityRank(severity string) int {
} }
} }
func autoShutdownAlertContainer(containerName, alertType, severity string) {
c := config.FindContainerByName(containerName)
if c == nil || c.Status != "running" {
return
}
reason := fmt.Sprintf("%s 告警触发策略临时封禁", alertType)
if severity != "" {
reason = fmt.Sprintf("[%s] %s", severity, reason)
}
config.SetContainerPolicyBlock(c.ID, true, reason)
taskID, queued := globalQueue.EnqueueSecurityStop(c.ID, c.Name)
if queued {
config.AddAuditLog("security_auto_shutdown", c.Name, fmt.Sprintf("[%s] %s 告警触发自动关机任务 %s", severity, alertType, taskID), "system")
}
}
// HandleSecurityAlerts returns all security alerts. // HandleSecurityAlerts returns all security alerts.
func HandleSecurityAlerts(w http.ResponseWriter, r *http.Request) { func HandleSecurityAlerts(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet { if r.Method != http.MethodGet {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return return
} }
if !requireScope(w, r, "security:read") {
ss := ensureScanner() return
ss.mu.Lock()
reversed := make([]SecurityAlert, len(ss.alerts))
for i, a := range ss.alerts {
reversed[len(ss.alerts)-1-i] = a
}
ss.mu.Unlock()
if reversed == nil {
reversed = []SecurityAlert{}
} }
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: reversed}) jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: filterSecurityAlertsForRequest(r, mergedSecurityAlerts())})
}
// HandleSecuritySettings returns or updates security automation settings.
func HandleSecuritySettings(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
if !requireScope(w, r, "security:read") {
return
}
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]bool{
"auto_shutdown": config.AppConfig.SecurityAutoShutdown,
}})
case http.MethodPut:
if !requireScope(w, r, "security:settings") {
return
}
var req struct {
AutoShutdown bool `json:"auto_shutdown"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
return
}
config.AppConfig.SecurityAutoShutdown = req.AutoShutdown
if err := config.SaveConfig(); err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
return
}
auditRequest(r, "security.settings", "auto_shutdown", fmt.Sprintf("auto_shutdown=%v", req.AutoShutdown), true, "")
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]bool{
"auto_shutdown": config.AppConfig.SecurityAutoShutdown,
}})
default:
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
}
} }
// HandleSecurityCheck triggers immediate security check for a container. // HandleSecurityCheck triggers immediate security check for a container.
@@ -648,6 +702,9 @@ func HandleSecurityCheck(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return return
} }
if !requireScope(w, r, "security:check") {
return
}
var req struct { var req struct {
ContainerName string `json:"container_name"` ContainerName string `json:"container_name"`
@@ -662,6 +719,10 @@ func HandleSecurityCheck(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found or not running"}) jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found or not running"})
return return
} }
if !isContainerAllowedForRequest(r, c.UUID) {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Access denied to this container"})
return
}
ensureScanner().checkContainer(c.Name, c.IP) ensureScanner().checkContainer(c.Name, c.IP)
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Security check completed"}) jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Security check completed"})
@@ -673,6 +734,9 @@ func HandleSecurityLogs(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return return
} }
if !requireScope(w, r, "security:read") {
return
}
containerName := r.URL.Query().Get("container") containerName := r.URL.Query().Get("container")
if containerName == "" { if containerName == "" {
@@ -685,6 +749,10 @@ func HandleSecurityLogs(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: []map[string]interface{}{}}) jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: []map[string]interface{}{}})
return return
} }
if !isContainerAllowedForRequest(r, c.UUID) {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Access denied to this container"})
return
}
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: getConnectionLogs(c.IP)}) jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: getConnectionLogs(c.IP)})
} }
@@ -737,14 +805,16 @@ func HandleContainerSecuritySummary(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return return
} }
if !requireScope(w, r, "security:read") {
return
}
ss := ensureScanner()
ss.mu.Lock()
critical := 0 critical := 0
high := 0 high := 0
medium := 0 medium := 0
low := 0 low := 0
for _, a := range ss.alerts { alerts := filterSecurityAlertsForRequest(r, mergedSecurityAlerts())
for _, a := range alerts {
switch a.Severity { switch a.Severity {
case "critical": case "critical":
critical++ critical++
@@ -756,8 +826,7 @@ func HandleContainerSecuritySummary(w http.ResponseWriter, r *http.Request) {
low++ low++
} }
} }
total := len(ss.alerts) total := len(alerts)
ss.mu.Unlock()
summary := map[string]interface{}{ summary := map[string]interface{}{
"total_alerts": total, "total_alerts": total,
@@ -769,3 +838,131 @@ func HandleContainerSecuritySummary(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: summary}) jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: summary})
} }
func filterSecurityAlertsForRequest(r *http.Request, alerts []SecurityAlert) []SecurityAlert {
allowed, restricted := requestAllowedContainers(r)
if !restricted {
return alerts
}
filtered := make([]SecurityAlert, 0, len(alerts))
for _, alert := range alerts {
if c := config.FindContainerByName(alert.ContainerName); c != nil && isContainerAllowed(allowed, c) {
filtered = append(filtered, alert)
}
}
return filtered
}
func mergedSecurityAlerts() []SecurityAlert {
ss := ensureScanner()
ss.mu.Lock()
alerts := make([]SecurityAlert, len(ss.alerts))
copy(alerts, ss.alerts)
ss.mu.Unlock()
seen := make(map[string]bool)
for _, alert := range alerts {
seen[securityAlertKey(alert)] = true
}
for i, log := range config.AppConfig.AuditLogs {
alert, ok := alertFromSecurityAuditLog(log, i)
if !ok {
continue
}
key := securityAlertKey(alert)
if seen[key] {
continue
}
seen[key] = true
alerts = append(alerts, alert)
}
sort.SliceStable(alerts, func(i, j int) bool {
ti, errI := time.Parse("2006-01-02 15:04:05", alerts[i].Timestamp)
tj, errJ := time.Parse("2006-01-02 15:04:05", alerts[j].Timestamp)
if errI == nil && errJ == nil && !ti.Equal(tj) {
return ti.After(tj)
}
return alerts[i].Timestamp > alerts[j].Timestamp
})
if len(alerts) > 200 {
alerts = alerts[:200]
}
if alerts == nil {
return []SecurityAlert{}
}
return alerts
}
func securityAlertKey(alert SecurityAlert) string {
return strings.Join([]string{
alert.Timestamp,
alert.ContainerName,
alert.Type,
alert.Detail,
strconv.Itoa(alert.TargetPort),
}, "\x1f")
}
func alertFromSecurityAuditLog(log config.AuditLog, index int) (SecurityAlert, bool) {
if !strings.HasPrefix(log.Action, "security_") || log.Action == "security_auto_shutdown" || log.Action == "security_policy_unblock" {
return SecurityAlert{}, false
}
alertType := strings.TrimPrefix(log.Action, "security_")
severity, detail := parseSecurityAuditDetail(log.Detail)
targetPort := parseDetailPort(detail)
targetIP := ""
if targetPort > 0 || alertType == "horizontal_scan" || alertType == "brute_force" {
targetIP = "*"
}
return SecurityAlert{
ID: fmt.Sprintf("audit-security-%d", index),
ContainerName: log.Target,
Type: alertType,
Severity: severity,
SourceIP: "",
TargetIP: targetIP,
TargetPort: targetPort,
Detail: detail,
LogLine: "",
Timestamp: log.Time,
Count: 1,
}, true
}
func parseSecurityAuditDetail(detail string) (string, string) {
severity := "medium"
if strings.HasPrefix(detail, "[") {
if end := strings.Index(detail, "]"); end > 1 {
severity = detail[1:end]
detail = strings.TrimSpace(detail[end+1:])
}
}
return severity, detail
}
func parseDetailPort(detail string) int {
for _, marker := range []string{"端口 ", "端口"} {
idx := strings.Index(detail, marker)
if idx == -1 {
continue
}
start := idx + len(marker)
for start < len(detail) && (detail[start] == ' ' || detail[start] == ':' || detail[start] == '(') {
start++
}
end := start
for end < len(detail) && detail[end] >= '0' && detail[end] <= '9' {
end++
}
if end > start {
port, _ := strconv.Atoi(detail[start:end])
return port
}
}
return 0
}
+3
View File
@@ -56,6 +56,9 @@ func HandleLoginLogs(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return return
} }
if !requireScope(w, r, "loginlog:read") {
return
}
// Return in reverse (newest first) // Return in reverse (newest first)
reversed := make([]LoginLog, len(loginLogs)) reversed := make([]LoginLog, len(loginLogs))
+37 -9
View File
@@ -16,7 +16,11 @@ func HandleSnapshots(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return return
} }
if !requireScope(w, r, "snapshot:read") {
return
}
snapshots := append([]config.Snapshot(nil), config.AppConfig.Snapshots...) snapshots := append([]config.Snapshot(nil), config.AppConfig.Snapshots...)
snapshots = filterSnapshotsForRequest(r, snapshots)
sortSnapshotsNewestFirst(snapshots) sortSnapshotsNewestFirst(snapshots)
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: snapshots}) jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: snapshots})
} }
@@ -24,17 +28,35 @@ func HandleSnapshots(w http.ResponseWriter, r *http.Request) {
func handleContainerSnapshots(w http.ResponseWriter, r *http.Request, containerID int, action string) { func handleContainerSnapshots(w http.ResponseWriter, r *http.Request, containerID int, action string) {
switch { switch {
case action == "snapshots" && r.Method == http.MethodGet: case action == "snapshots" && r.Method == http.MethodGet:
if !requireScope(w, r, "snapshot:read") {
return
}
listContainerSnapshots(w, r, containerID) listContainerSnapshots(w, r, containerID)
case action == "snapshots" && r.Method == http.MethodPost: case action == "snapshots" && r.Method == http.MethodPost:
if !requireScope(w, r, "snapshot:create") {
return
}
createContainerSnapshot(w, r, containerID) createContainerSnapshot(w, r, containerID)
case action == "snapshots/schedule" && r.Method == http.MethodPost: case action == "snapshots/schedule" && r.Method == http.MethodPost:
if !requireScope(w, r, "snapshot:schedule") {
return
}
updateSnapshotSchedule(w, r, containerID) updateSnapshotSchedule(w, r, containerID)
case action == "snapshots/quota" && r.Method == http.MethodPut: case action == "snapshots/quota" && r.Method == http.MethodPut:
if !requireScope(w, r, "snapshot:schedule") {
return
}
updateSnapshotQuota(w, r, containerID) updateSnapshotQuota(w, r, containerID)
case strings.HasPrefix(action, "snapshots/") && strings.HasSuffix(action, "/restore") && r.Method == http.MethodPost: case strings.HasPrefix(action, "snapshots/") && strings.HasSuffix(action, "/restore") && r.Method == http.MethodPost:
if !requireScope(w, r, "snapshot:restore") {
return
}
snapshotID := strings.TrimSuffix(strings.TrimPrefix(action, "snapshots/"), "/restore") snapshotID := strings.TrimSuffix(strings.TrimPrefix(action, "snapshots/"), "/restore")
restoreContainerSnapshot(w, r, containerID, snapshotID) restoreContainerSnapshot(w, r, containerID, snapshotID)
case strings.HasPrefix(action, "snapshots/") && r.Method == http.MethodDelete: case strings.HasPrefix(action, "snapshots/") && r.Method == http.MethodDelete:
if !requireScope(w, r, "snapshot:delete") {
return
}
snapshotID := strings.TrimPrefix(action, "snapshots/") snapshotID := strings.TrimPrefix(action, "snapshots/")
deleteContainerSnapshot(w, r, containerID, snapshotID) deleteContainerSnapshot(w, r, containerID, snapshotID)
default: default:
@@ -186,15 +208,7 @@ func restoreContainerSnapshot(w http.ResponseWriter, r *http.Request, containerI
} }
func requestUser(r *http.Request) string { func requestUser(r *http.Request) string {
if claims, ok := claimsFromRequest(r); ok { return requestActor(r)
if subUser, _ := claims["sub_user"].(string); subUser != "" {
return "user:" + subUser
}
if username, _ := claims["username"].(string); username != "" {
return username
}
}
return "admin"
} }
func sortSnapshotsNewestFirst(snapshots []config.Snapshot) { func sortSnapshotsNewestFirst(snapshots []config.Snapshot) {
@@ -204,3 +218,17 @@ func sortSnapshotsNewestFirst(snapshots []config.Snapshot) {
return tj.Before(ti) return tj.Before(ti)
}) })
} }
func filterSnapshotsForRequest(r *http.Request, snapshots []config.Snapshot) []config.Snapshot {
allowed, restricted := requestAllowedContainers(r)
if !restricted {
return snapshots
}
filtered := make([]config.Snapshot, 0, len(snapshots))
for _, snapshot := range snapshots {
if c := config.FindContainer(snapshot.ContainerID); c != nil && isContainerAllowed(allowed, c) {
filtered = append(filtered, snapshot)
}
}
return filtered
}
+20 -5
View File
@@ -27,6 +27,7 @@ type terminalResizeMessage struct {
type webSSHTicket struct { type webSSHTicket struct {
ContainerName string ContainerName string
SubUser bool
ExpiresAt time.Time ExpiresAt time.Time
} }
@@ -41,6 +42,9 @@ func HandleWebSSHTicket(w http.ResponseWriter, r *http.Request) {
return return
} }
if !requireScope(w, r, "terminal:ssh") {
return
}
var req struct { var req struct {
ContainerName string `json:"container_name"` ContainerName string `json:"container_name"`
} }
@@ -52,16 +56,22 @@ func HandleWebSSHTicket(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Access denied to this container"}) jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Access denied to this container"})
return return
} }
if config.FindContainerByName(req.ContainerName) == nil { c := config.FindContainerByName(req.ContainerName)
if c == nil {
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"}) jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"})
return return
} }
if isSubUserRequest(r) && c.PolicyBlocked {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: policyBlockedMessage(c)})
return
}
ticket := randomHex(32) ticket := randomHex(32)
webSSHTickets.Lock() webSSHTickets.Lock()
cleanupExpiredWebSSHTicketsLocked(time.Now()) cleanupExpiredWebSSHTicketsLocked(time.Now())
webSSHTickets.items[ticket] = webSSHTicket{ webSSHTickets.items[ticket] = webSSHTicket{
ContainerName: req.ContainerName, ContainerName: req.ContainerName,
SubUser: isSubUserRequest(r),
ExpiresAt: time.Now().Add(60 * time.Second), ExpiresAt: time.Now().Add(60 * time.Second),
} }
webSSHTickets.Unlock() webSSHTickets.Unlock()
@@ -86,7 +96,8 @@ func HandleWebSSH(w http.ResponseWriter, r *http.Request) {
return return
} }
if !consumeWebSSHTicket(ticket, containerName) { item, ok := consumeWebSSHTicket(ticket, containerName)
if !ok {
http.Error(w, "invalid or expired ticket", http.StatusUnauthorized) http.Error(w, "invalid or expired ticket", http.StatusUnauthorized)
return return
} }
@@ -96,6 +107,10 @@ func HandleWebSSH(w http.ResponseWriter, r *http.Request) {
http.Error(w, "container not found", http.StatusNotFound) http.Error(w, "container not found", http.StatusNotFound)
return return
} }
if item.SubUser && c.PolicyBlocked {
http.Error(w, "虚拟机被策略临时封禁", http.StatusForbidden)
return
}
if c.Status != "running" { if c.Status != "running" {
http.Error(w, "container is not running", http.StatusBadRequest) http.Error(w, "container is not running", http.StatusBadRequest)
return return
@@ -359,17 +374,17 @@ func writeWebSocketText(ws *websocket.Conn, writeMu *sync.Mutex, msg string) {
_ = ws.WriteMessage(websocket.TextMessage, []byte(msg)) _ = ws.WriteMessage(websocket.TextMessage, []byte(msg))
} }
func consumeWebSSHTicket(ticket, containerName string) bool { func consumeWebSSHTicket(ticket, containerName string) (webSSHTicket, bool) {
now := time.Now() now := time.Now()
webSSHTickets.Lock() webSSHTickets.Lock()
defer webSSHTickets.Unlock() defer webSSHTickets.Unlock()
cleanupExpiredWebSSHTicketsLocked(now) cleanupExpiredWebSSHTicketsLocked(now)
item, ok := webSSHTickets.items[ticket] item, ok := webSSHTickets.items[ticket]
if !ok { if !ok {
return false return webSSHTicket{}, false
} }
delete(webSSHTickets.items, ticket) delete(webSSHTickets.items, ticket)
return item.ContainerName == containerName && now.Before(item.ExpiresAt) return item, item.ContainerName == containerName && now.Before(item.ExpiresAt)
} }
func cleanupExpiredWebSSHTicketsLocked(now time.Time) { func cleanupExpiredWebSSHTicketsLocked(now time.Time) {
+119 -28
View File
@@ -49,6 +49,9 @@ func HandleSubUserCreate(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return return
} }
if !requireScope(w, r, "subuser:create") {
return
}
var req struct { var req struct {
ContainerName string `json:"container_name"` ContainerName string `json:"container_name"`
@@ -281,13 +284,40 @@ func subUserAllowedContainers(r *http.Request) (subUserAccess, bool) {
return allowed, true return allowed, true
} }
func requestAllowedContainers(r *http.Request) (subUserAccess, bool) {
if ctx, ok := authContextFromRequest(r); ok {
if ctx.Type == authTypeAPIKey && len(ctx.ContainerUUIDs) == 0 {
return subUserAccess{}, false
}
if ctx.Type == authTypeSubUser || ctx.Type == authTypeAPIKey {
allowed := subUserAccess{names: make(map[string]bool), uuids: make(map[string]bool)}
for _, uuid := range ctx.ContainerUUIDs {
allowed.uuids[uuid] = true
}
if ctx.Type == authTypeSubUser && len(ctx.ContainerUUIDs) == 0 {
legacy, ok := subUserAllowedContainers(r)
if ok {
return legacy, true
}
}
return allowed, true
}
}
return subUserAllowedContainers(r)
}
func isAccessRestrictedRequest(r *http.Request) bool {
_, restricted := requestAllowedContainers(r)
return restricted
}
func containerByIdentifier(identifier string) *config.Container { func containerByIdentifier(identifier string) *config.Container {
return config.FindContainerByIdentifier(identifier) return config.FindContainerByIdentifier(identifier)
} }
func isContainerAllowedForRequest(r *http.Request, identifier string) bool { func isContainerAllowedForRequest(r *http.Request, identifier string) bool {
allowed, isSubUser := subUserAllowedContainers(r) allowed, restricted := requestAllowedContainers(r)
if !isSubUser { if !restricted {
return true return true
} }
c := containerByIdentifier(identifier) c := containerByIdentifier(identifier)
@@ -303,6 +333,9 @@ func HandleAuditLogs(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return return
} }
if !requireScope(w, r, "audit:read") {
return
}
logs := config.AppConfig.AuditLogs logs := config.AppConfig.AuditLogs
if logs == nil { if logs == nil {
@@ -327,12 +360,20 @@ func SubUserMiddleware(next http.HandlerFunc) http.HandlerFunc {
} }
path := r.URL.Path path := r.URL.Path
if path == "/api/tasks" && r.Method == http.MethodGet { containerPrefix := "/api/containers/"
containerListPath := "/api/containers"
tasksPath := "/api/tasks"
if strings.HasPrefix(path, "/api/v1/") {
containerPrefix = "/api/v1/containers/"
containerListPath = "/api/v1/containers"
tasksPath = "/api/v1/tasks"
}
if path == tasksPath && r.Method == http.MethodGet {
next(w, r) next(w, r)
return return
} }
if path == "/api/containers" { if path == containerListPath {
if r.Method != http.MethodGet { if r.Method != http.MethodGet {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Sub-users cannot create containers"}) jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Sub-users cannot create containers"})
return return
@@ -341,8 +382,8 @@ func SubUserMiddleware(next http.HandlerFunc) http.HandlerFunc {
return return
} }
if len(path) > len("/api/containers/") { if strings.HasPrefix(path, containerPrefix) {
rest := path[len("/api/containers/"):] rest := path[len(containerPrefix):]
parts := splitPath(rest) parts := splitPath(rest)
if len(parts) > 0 && parts[0] != "" { if len(parts) > 0 && parts[0] != "" {
c := containerByIdentifier(parts[0]) c := containerByIdentifier(parts[0])
@@ -352,7 +393,11 @@ func SubUserMiddleware(next http.HandlerFunc) http.HandlerFunc {
} }
action := "" action := ""
if len(parts) > 1 { if len(parts) > 1 {
action = parts[1] action = strings.Join(parts[1:], "/")
}
if c.PolicyBlocked && isSubUserBlockedAction(action, r.Method) {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: policyBlockedMessage(c)})
return
} }
if !isSubUserContainerActionAllowed(action, r.Method) { if !isSubUserContainerActionAllowed(action, r.Method) {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Action is not allowed for this link"}) jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Action is not allowed for this link"})
@@ -369,8 +414,8 @@ func SubUserMiddleware(next http.HandlerFunc) http.HandlerFunc {
} }
func filterContainersForRequest(r *http.Request, containers []config.Container) []config.Container { func filterContainersForRequest(r *http.Request, containers []config.Container) []config.Container {
allowed, isSubUser := subUserAllowedContainers(r) allowed, restricted := requestAllowedContainers(r)
if !isSubUser { if !restricted {
return containers return containers
} }
filtered := make([]config.Container, 0, len(containers)) filtered := make([]config.Container, 0, len(containers))
@@ -383,33 +428,66 @@ func filterContainersForRequest(r *http.Request, containers []config.Container)
} }
func filterTasksForRequest(r *http.Request, tasks []*Task) []*Task { func filterTasksForRequest(r *http.Request, tasks []*Task) []*Task {
allowed, isSubUser := subUserAllowedContainers(r)
if !isSubUser {
return tasks
}
filtered := make([]*Task, 0, len(tasks)) filtered := make([]*Task, 0, len(tasks))
for _, task := range tasks { for _, task := range tasks {
if c := config.FindContainer(task.ContainerID); c != nil && isContainerAllowed(allowed, c) { if isTaskAllowedForRequest(r, task) {
filtered = append(filtered, task) filtered = append(filtered, task)
continue
}
if task.ContainerName != "" {
if c := config.FindContainerByName(task.ContainerName); c != nil && isContainerAllowed(allowed, c) {
filtered = append(filtered, task)
continue
}
}
if task.Config.Name != "" {
if c := config.FindContainerByName(task.Config.Name); c != nil && isContainerAllowed(allowed, c) {
filtered = append(filtered, task)
}
} }
} }
return filtered return filtered
} }
func isTaskAllowedForRequest(r *http.Request, task *Task) bool {
allowed, restricted := requestAllowedContainers(r)
if !restricted {
return true
}
if task == nil {
return false
}
if c := config.FindContainer(task.ContainerID); c != nil && isContainerAllowed(allowed, c) {
return true
}
if task.ContainerName != "" {
if c := config.FindContainerByName(task.ContainerName); c != nil && isContainerAllowed(allowed, c) {
return true
}
}
if task.Config.Name != "" {
if c := config.FindContainerByName(task.Config.Name); c != nil && isContainerAllowed(allowed, c) {
return true
}
}
return false
}
func isContainerAllowed(allowed subUserAccess, c *config.Container) bool { func isContainerAllowed(allowed subUserAccess, c *config.Container) bool {
return c != nil && c.UUID != "" && allowed.uuids[c.UUID] if c == nil {
return false
}
if c.UUID != "" && allowed.uuids[c.UUID] {
return true
}
return c.Name != "" && allowed.names[c.Name]
}
func isSubUserBlockedAction(action string, method string) bool {
if action == "" {
return method != http.MethodGet
}
switch action {
case "usage", "traffic":
return method != http.MethodGet
default:
return true
}
}
func policyBlockedMessage(c *config.Container) string {
if c != nil && c.PolicyBlockedReason != "" {
return "虚拟机被策略临时封禁:" + c.PolicyBlockedReason
}
return "虚拟机被策略临时封禁"
} }
func isSubUserContainerActionAllowed(action string, method string) bool { func isSubUserContainerActionAllowed(action string, method string) bool {
@@ -513,6 +591,9 @@ func HandleSubUserList(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return return
} }
if !requireScope(w, r, "subuser:read") {
return
}
result := make([]SubUserListItem, 0, len(config.AppConfig.SubUsers)) result := make([]SubUserListItem, 0, len(config.AppConfig.SubUsers))
for _, su := range config.AppConfig.SubUsers { for _, su := range config.AppConfig.SubUsers {
@@ -562,7 +643,8 @@ func HandleSubUserList(w http.ResponseWriter, r *http.Request) {
// HandleSubUserAction handles actions on a specific sub-user // HandleSubUserAction handles actions on a specific sub-user
func HandleSubUserAction(w http.ResponseWriter, r *http.Request) { func HandleSubUserAction(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/api/sub-users/") path := strings.TrimPrefix(r.URL.Path, "/api/v1/sub-users/")
path = strings.TrimPrefix(path, "/api/sub-users/")
parts := strings.SplitN(path, "/", 2) parts := strings.SplitN(path, "/", 2)
subUserID := parts[0] subUserID := parts[0]
action := "" action := ""
@@ -585,6 +667,9 @@ func HandleSubUserAction(w http.ResponseWriter, r *http.Request) {
switch { switch {
case action == "rotate-password" && r.Method == http.MethodPost: case action == "rotate-password" && r.Method == http.MethodPost:
if !requireScope(w, r, "subuser:update") {
return
}
password := generateRandomStr(16) password := generateRandomStr(16)
if hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost); err == nil { if hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost); err == nil {
target.PassHash = string(hash) target.PassHash = string(hash)
@@ -602,11 +687,17 @@ func HandleSubUserAction(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to generate password"}) jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to generate password"})
case action == "audit-logs" && r.Method == http.MethodGet: case action == "audit-logs" && r.Method == http.MethodGet:
if !requireScope(w, r, "audit:read") {
return
}
// Filter audit logs for this sub-user // Filter audit logs for this sub-user
logs := filterSubUserAuditLogs(target.Username) logs := filterSubUserAuditLogs(target.Username)
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: logs}) jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: logs})
case action == "login-logs" && r.Method == http.MethodGet: case action == "login-logs" && r.Method == http.MethodGet:
if !requireScope(w, r, "loginlog:read") {
return
}
// Filter login logs for this sub-user // Filter login logs for this sub-user
logs := filterSubUserLoginLogs(target.Username) logs := filterSubUserLoginLogs(target.Username)
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: logs}) jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: logs})
+50 -27
View File
@@ -11,19 +11,27 @@ import (
) )
type SwapInfo struct { type SwapInfo struct {
TotalMB int64 `json:"total_mb"` TotalMB int64 `json:"total_mb"`
UsedMB int64 `json:"used_mb"` UsedMB int64 `json:"used_mb"`
FreeMB int64 `json:"free_mb"` FreeMB int64 `json:"free_mb"`
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
SwapFile string `json:"swap_file"` SwapFile string `json:"swap_file"`
} }
const (
minSwapSizeMB = 128
maxSwapSizeMB = 262144
)
// HandleSwapInfo returns current swap status // HandleSwapInfo returns current swap status
func HandleSwapInfo(w http.ResponseWriter, r *http.Request) { func HandleSwapInfo(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet { if r.Method != http.MethodGet {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return return
} }
if !requireScope(w, r, "swap:read") {
return
}
info := getSwapInfo() info := getSwapInfo()
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: info}) jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: info})
@@ -35,9 +43,12 @@ func HandleSwapManage(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return return
} }
if !requireScope(w, r, "swap:manage") {
return
}
var req struct { var req struct {
Action string `json:"action"` // create, enable, disable, resize Action string `json:"action"` // create, enable, disable, resize
SizeMB int `json:"size_mb"` // for create/resize SizeMB int `json:"size_mb"` // for create/resize
} }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@@ -46,54 +57,63 @@ func HandleSwapManage(w http.ResponseWriter, r *http.Request) {
} }
var msg string var msg string
var err error
switch req.Action { switch req.Action {
case "create": case "create":
if req.SizeMB <= 0 { if req.SizeMB <= 0 {
req.SizeMB = 2048 req.SizeMB = 2048
} }
err := createSwap(req.SizeMB) if err = validateSwapSize(req.SizeMB); err == nil {
if err != nil { err = createSwap(req.SizeMB)
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
return
} }
msg = fmt.Sprintf("已创建 %d MB SWAP", req.SizeMB) msg = fmt.Sprintf("已创建 %d MB SWAP", req.SizeMB)
case "enable": case "enable":
err := enableSwap() err = enableSwap()
if err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
return
}
msg = "SWAP 已启用" msg = "SWAP 已启用"
case "disable": case "disable":
err := disableSwap() err = disableSwap()
if err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
return
}
msg = "SWAP 已禁用" msg = "SWAP 已禁用"
case "resize": case "resize":
if req.SizeMB <= 0 { if err = validateSwapSize(req.SizeMB); err == nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid size"}) err = disableSwap()
return }
if err == nil {
err = createSwap(req.SizeMB)
}
if err == nil {
err = enableSwap()
} }
disableSwap()
createSwap(req.SizeMB)
enableSwap()
msg = fmt.Sprintf("SWAP 已调整为 %d MB", req.SizeMB) msg = fmt.Sprintf("SWAP 已调整为 %d MB", req.SizeMB)
default: default:
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid action: " + req.Action}) jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid action: " + req.Action})
return return
} }
if err != nil {
auditRequest(r, "swap."+req.Action, "/swapfile", fmt.Sprintf("size_mb=%d", req.SizeMB), false, err.Error())
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
return
}
info := getSwapInfo() info := getSwapInfo()
auditRequest(r, "swap."+req.Action, "/swapfile", fmt.Sprintf("size_mb=%d", req.SizeMB), true, "")
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: msg, Data: info}) jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: msg, Data: info})
} }
func validateSwapSize(sizeMB int) error {
if sizeMB < minSwapSizeMB {
return fmt.Errorf("swap size must be at least %d MB", minSwapSizeMB)
}
if sizeMB > maxSwapSizeMB {
return fmt.Errorf("swap size cannot exceed %d MB", maxSwapSizeMB)
}
return nil
}
func getSwapInfo() SwapInfo { func getSwapInfo() SwapInfo {
info := SwapInfo{SwapFile: "/swapfile"} info := SwapInfo{SwapFile: "/swapfile"}
@@ -160,6 +180,9 @@ func createSwap(sizeMB int) error {
func enableSwap() error { func enableSwap() error {
swapFile := "/swapfile" swapFile := "/swapfile"
if _, err := os.Stat(swapFile); os.IsNotExist(err) { if _, err := os.Stat(swapFile); os.IsNotExist(err) {
if getSwapInfo().Enabled {
return nil
}
return fmt.Errorf("swap 文件不存在,请先创建") return fmt.Errorf("swap 文件不存在,请先创建")
} }
@@ -180,7 +203,7 @@ func disableSwap() error {
cmd := exec.Command("swapoff", swapFile) cmd := exec.Command("swapoff", swapFile)
output, err := cmd.CombinedOutput() output, err := cmd.CombinedOutput()
if err != nil { if err != nil {
if strings.Contains(string(output), "No such") { if strings.Contains(string(output), "No such") || strings.Contains(string(output), "Invalid argument") {
return nil return nil
} }
return fmt.Errorf("禁用 swap 失败: %v, %s", err, string(output)) return fmt.Errorf("禁用 swap 失败: %v, %s", err, string(output))
+101 -13
View File
@@ -122,9 +122,13 @@ func (q *TaskQueue) EnqueueBatchWithAudit(taskType TaskType, ids []int, template
} }
func (q *TaskQueue) EnqueueBatchCreate(configs []lxc.ContainerConfig) []string { func (q *TaskQueue) EnqueueBatchCreate(configs []lxc.ContainerConfig) []string {
return q.EnqueueBatchCreateWithAudit(configs, "admin", "", "")
}
func (q *TaskQueue) EnqueueBatchCreateWithAudit(configs []lxc.ContainerConfig, user string, ip string, userAgent string) []string {
q.mu.Lock() q.mu.Lock()
defer q.mu.Unlock() defer q.mu.Unlock()
return q.enqueueBatchCreateList(configs) return q.enqueueBatchCreateList(configs, user, ip, userAgent)
} }
func (q *TaskQueue) ActiveCreateNames() map[string]bool { func (q *TaskQueue) ActiveCreateNames() map[string]bool {
@@ -147,7 +151,7 @@ func (q *TaskQueue) ActiveCreateNames() map[string]bool {
return names return names
} }
func (q *TaskQueue) enqueueBatchCreateList(configs []lxc.ContainerConfig) []string { func (q *TaskQueue) enqueueBatchCreateList(configs []lxc.ContainerConfig, user string, ip string, userAgent string) []string {
var result []string var result []string
for _, cfg := range configs { for _, cfg := range configs {
cfgCopy := cfg cfgCopy := cfg
@@ -161,6 +165,9 @@ func (q *TaskQueue) enqueueBatchCreateList(configs []lxc.ContainerConfig) []stri
Status: "pending", Status: "pending",
CreatedAt: time.Now().Format("2006-01-02 15:04:05"), CreatedAt: time.Now().Format("2006-01-02 15:04:05"),
Config: cfgCopy, Config: cfgCopy,
User: user,
IP: ip,
UserAgent: userAgent,
} }
q.enqueueTask(task) q.enqueueTask(task)
result = append(result, task.ID) result = append(result, task.ID)
@@ -196,6 +203,24 @@ func (q *TaskQueue) enqueueSingleWithAudit(containerID int, containerName string
return task.ID return task.ID
} }
func (q *TaskQueue) EnqueueSecurityStop(containerID int, containerName string) (string, bool) {
q.mu.Lock()
defer q.mu.Unlock()
for _, task := range q.tasks {
if task.Type != TaskStop || task.ContainerID != containerID {
continue
}
if task.Status == "pending" || task.Status == "running" {
return task.ID, false
}
}
taskID := q.enqueueSingleWithAudit(containerID, containerName, TaskStop, "", "system:security", "", "")
q.persistTasks()
return taskID, true
}
// createWorker handles TaskCreate: lxc-create, resource setup, start, and SSH init. // 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 // If a restored task already has a same-name container in config, it resumes
// initialization instead of creating another ct-{id}. // initialization instead of creating another ct-{id}.
@@ -337,10 +362,14 @@ func (q *TaskQueue) opWorker() {
switch task.Type { switch task.Type {
case TaskStart: case TaskStart:
config.UpdateContainerStatus(task.ContainerID, "running") config.UpdateContainerStatus(task.ContainerID, "running")
clearPolicyBlockAfterAdminRecovery(task)
case TaskStop: case TaskStop:
config.UpdateContainerStatus(task.ContainerID, "stopped") config.UpdateContainerStatus(task.ContainerID, "stopped")
case TaskRestart: case TaskRestart:
config.UpdateContainerStatus(task.ContainerID, "running") config.UpdateContainerStatus(task.ContainerID, "running")
clearPolicyBlockAfterAdminRecovery(task)
case TaskReinstall:
clearPolicyBlockAfterAdminRecovery(task)
} }
} }
q.persistTasks() q.persistTasks()
@@ -348,6 +377,17 @@ func (q *TaskQueue) opWorker() {
} }
} }
func clearPolicyBlockAfterAdminRecovery(task *Task) {
if task == nil || strings.HasPrefix(task.User, "user:") || task.User == "system:security" {
return
}
c := config.FindContainer(task.ContainerID)
if c != nil && c.PolicyBlocked {
config.SetContainerPolicyBlock(c.ID, false, "")
config.AddAuditLog("security_policy_unblock", c.Name, "管理员操作后解除策略临时封禁", task.User)
}
}
func resolveTaskContainer(task *Task) error { func resolveTaskContainer(task *Task) error {
if task.Type == TaskCreate { if task.Type == TaskCreate {
return nil return nil
@@ -391,6 +431,8 @@ func (q *TaskQueue) persistTasks() {
TemplateID: t.TemplateID, TemplateID: t.TemplateID,
Config: string(cfgJSON), Config: string(cfgJSON),
User: t.User, User: t.User,
IP: t.IP,
UserAgent: t.UserAgent,
}) })
} }
config.SaveTasks(saved) config.SaveTasks(saved)
@@ -423,13 +465,8 @@ func HandleSingleTaskAction(w http.ResponseWriter, r *http.Request, id int, acti
name = c.Name name = c.Name
} }
// Determine user from JWT claims // Determine user from authenticated request context.
user := "admin" user := requestActor(r)
if claims, ok := claimsFromRequest(r); ok {
if subUser, _ := claims["sub_user"].(string); subUser != "" {
user = "user:" + subUser
}
}
ip := clientIP(r) ip := clientIP(r)
userAgent := r.Header.Get("User-Agent") userAgent := r.Header.Get("User-Agent")
@@ -484,6 +521,13 @@ func HandleBatchCreate(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return return
} }
if !requireScope(w, r, "container:create") {
return
}
if isAccessRestrictedRequest(r) {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Container-bound API keys cannot create containers"})
return
}
var req struct { var req struct {
Containers []lxc.ContainerConfig `json:"containers"` Containers []lxc.ContainerConfig `json:"containers"`
} }
@@ -543,7 +587,7 @@ func HandleBatchCreate(w http.ResponseWriter, r *http.Request) {
} }
requestNames[name] = true requestNames[name] = true
} }
ids := globalQueue.EnqueueBatchCreate(req.Containers) ids := globalQueue.EnqueueBatchCreateWithAudit(req.Containers, requestActor(r), clientIP(r), r.UserAgent())
jsonResponse(w, http.StatusAccepted, APIResponse{Success: true, Data: ids}) jsonResponse(w, http.StatusAccepted, APIResponse{Success: true, Data: ids})
} }
@@ -553,6 +597,10 @@ func HandleBatchAction(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return return
} }
if !hasAnyScope(r, "container:power", "container:delete", "container:reinstall") {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Insufficient API key scope"})
return
}
var req struct { var req struct {
Action string `json:"action"` Action string `json:"action"`
Containers []int `json:"containers"` Containers []int `json:"containers"`
@@ -564,21 +612,47 @@ func HandleBatchAction(w http.ResponseWriter, r *http.Request) {
} }
var taskType TaskType var taskType TaskType
var requiredScope string
switch req.Action { switch req.Action {
case "start": case "start":
taskType = TaskStart taskType = TaskStart
requiredScope = "container:power"
case "stop": case "stop":
taskType = TaskStop taskType = TaskStop
requiredScope = "container:power"
case "restart": case "restart":
taskType = TaskRestart taskType = TaskRestart
requiredScope = "container:power"
case "delete": case "delete":
taskType = TaskDelete taskType = TaskDelete
requiredScope = "container:delete"
case "reinstall":
if req.TemplateID == "" {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "template_id required"})
return
}
if !isTemplateEnabledAndDownloaded(req.TemplateID) {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Template is not enabled or downloaded"})
return
}
taskType = TaskReinstall
requiredScope = "container:reinstall"
default: default:
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Unknown action"}) jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Unknown action"})
return return
} }
if !requireScope(w, r, requiredScope) {
return
}
for _, id := range req.Containers {
c := config.FindContainer(id)
if c == nil || !isContainerAllowedForRequest(r, c.UUID) {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Access denied to one or more containers"})
return
}
}
ids := globalQueue.EnqueueBatch(taskType, req.Containers, req.TemplateID) ids := globalQueue.EnqueueBatchWithAudit(taskType, req.Containers, req.TemplateID, requestActor(r), clientIP(r), r.UserAgent())
jsonResponse(w, http.StatusAccepted, APIResponse{Success: true, Data: ids}) jsonResponse(w, http.StatusAccepted, APIResponse{Success: true, Data: ids})
} }
@@ -588,13 +662,22 @@ func HandleTaskDelete(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return return
} }
// URL: /api/tasks/{id} if !requireScope(w, r, "task:delete") {
taskID := strings.TrimPrefix(r.URL.Path, "/api/tasks/") return
}
// URL: /api/tasks/{id} or /api/v1/tasks/{id}
taskID := strings.TrimPrefix(r.URL.Path, "/api/v1/tasks/")
taskID = strings.TrimPrefix(taskID, "/api/tasks/")
if taskID == "" { if taskID == "" {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Task ID required"}) jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Task ID required"})
return return
} }
globalQueue.mu.Lock() globalQueue.mu.Lock()
if task := globalQueue.tasks[taskID]; task != nil && !isTaskAllowedForRequest(r, task) {
globalQueue.mu.Unlock()
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Access denied to this task"})
return
}
delete(globalQueue.tasks, taskID) delete(globalQueue.tasks, taskID)
// Also remove from both queues if pending // Also remove from both queues if pending
newCreate := make([]*Task, 0, len(globalQueue.createQueue)) newCreate := make([]*Task, 0, len(globalQueue.createQueue))
@@ -622,6 +705,9 @@ func HandleTasks(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return return
} }
if !requireScope(w, r, "task:read") {
return
}
tasks := globalQueue.GetTasks() tasks := globalQueue.GetTasks()
tasks = filterTasksForRequest(r, tasks) tasks = filterTasksForRequest(r, tasks)
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: tasks}) jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: tasks})
@@ -658,6 +744,8 @@ func RestoreTasks() {
TemplateID: st.TemplateID, TemplateID: st.TemplateID,
Config: cfg, Config: cfg,
User: st.User, User: st.User,
IP: st.IP,
UserAgent: st.UserAgent,
} }
if st.Status == "pending" || st.Status == "running" { if st.Status == "pending" || st.Status == "running" {
// Reset running tasks back to pending so they get retried // Reset running tasks back to pending so they get retried
+52 -5
View File
@@ -18,6 +18,10 @@ import (
type webVNCTicket struct { type webVNCTicket struct {
ContainerName string ContainerName string
ContainerUUID string ContainerUUID string
Username string
SubUser bool
ClientIP string
UserAgent string
ExpiresAt time.Time ExpiresAt time.Time
} }
@@ -32,6 +36,9 @@ func HandleVNCTicket(w http.ResponseWriter, r *http.Request) {
return return
} }
if !requireScope(w, r, "terminal:vnc") {
return
}
var req struct { var req struct {
ContainerName string `json:"container_name"` ContainerName string `json:"container_name"`
} }
@@ -48,17 +55,26 @@ func HandleVNCTicket(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"}) jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"})
return return
} }
if isSubUserRequest(r) && c.PolicyBlocked {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: policyBlockedMessage(c)})
return
}
if !c.IsKVM() { if !c.IsKVM() {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "VNC console is only available for KVM VMs"}) jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "VNC console is only available for KVM VMs"})
return return
} }
username, isSubUser := vncRequesterIdentity(r)
ticket := randomHex(32) ticket := randomHex(32)
webVNCTickets.Lock() webVNCTickets.Lock()
cleanupExpiredWebVNCTicketsLocked(time.Now()) cleanupExpiredWebVNCTicketsLocked(time.Now())
webVNCTickets.items[ticket] = webVNCTicket{ webVNCTickets.items[ticket] = webVNCTicket{
ContainerName: c.Name, ContainerName: c.Name,
ContainerUUID: c.UUID, ContainerUUID: c.UUID,
Username: username,
SubUser: isSubUser,
ClientIP: clientIP(r),
UserAgent: r.UserAgent(),
ExpiresAt: time.Now().Add(60 * time.Second), ExpiresAt: time.Now().Add(60 * time.Second),
} }
webVNCTickets.Unlock() webVNCTickets.Unlock()
@@ -83,7 +99,7 @@ func HandleVNCProxy(w http.ResponseWriter, r *http.Request) {
return return
} }
item, ok := consumeWebVNCTicket(ticket, containerName) item, ok := consumeWebVNCTicket(ticket, containerName, r)
if !ok { if !ok {
http.Error(w, "invalid or expired ticket", http.StatusUnauthorized) http.Error(w, "invalid or expired ticket", http.StatusUnauthorized)
return return
@@ -94,6 +110,10 @@ func HandleVNCProxy(w http.ResponseWriter, r *http.Request) {
http.Error(w, "container not found", http.StatusNotFound) http.Error(w, "container not found", http.StatusNotFound)
return return
} }
if item.SubUser && c.PolicyBlocked {
http.Error(w, "虚拟机被策略临时封禁", http.StatusForbidden)
return
}
if !c.IsKVM() { if !c.IsKVM() {
http.Error(w, "VNC console is only available for KVM VMs", http.StatusBadRequest) http.Error(w, "VNC console is only available for KVM VMs", http.StatusBadRequest)
return return
@@ -127,7 +147,7 @@ func HandleVNCProxy(w http.ResponseWriter, r *http.Request) {
} }
defer ws.Close() defer ws.Close()
log.Printf("WebVNC connected for container %s -> 127.0.0.1:%d", containerName, vncPort) log.Printf("WebVNC connected for container %s as %s (sub_user=%t) -> 127.0.0.1:%d", containerName, item.Username, item.SubUser, vncPort)
done := make(chan string, 2) done := make(chan string, 2)
var writeMu sync.Mutex var writeMu sync.Mutex
@@ -137,7 +157,31 @@ func HandleVNCProxy(w http.ResponseWriter, r *http.Request) {
reason := <-done reason := <-done
_ = vncConn.Close() _ = vncConn.Close()
_ = ws.Close() _ = ws.Close()
log.Printf("WebVNC disconnected for container %s: %s", containerName, reason) log.Printf("WebVNC disconnected for container %s as %s: %s", containerName, item.Username, reason)
}
func vncRequesterIdentity(r *http.Request) (string, bool) {
if ctx, ok := authContextFromRequest(r); ok {
switch ctx.Type {
case authTypeSubUser:
return ctx.Username, true
case authTypeAPIKey:
return ctx.Actor, false
case authTypeAdmin:
return ctx.Username, false
}
}
claims, ok := claimsFromRequest(r)
if !ok {
return "api-key", false
}
if subUser, ok := claims["sub_user"].(string); ok && subUser != "" {
return subUser, true
}
if username, ok := claims["username"].(string); ok && username != "" {
return username, false
}
return "unknown", false
} }
func webVNCTicketFromRequest(r *http.Request) string { func webVNCTicketFromRequest(r *http.Request) string {
@@ -165,7 +209,7 @@ func webVNCResponseProtocol(r *http.Request) string {
return "" return ""
} }
func consumeWebVNCTicket(ticket, containerName string) (webVNCTicket, bool) { func consumeWebVNCTicket(ticket, containerName string, r *http.Request) (webVNCTicket, bool) {
now := time.Now() now := time.Now()
webVNCTickets.Lock() webVNCTickets.Lock()
defer webVNCTickets.Unlock() defer webVNCTickets.Unlock()
@@ -175,7 +219,10 @@ func consumeWebVNCTicket(ticket, containerName string) (webVNCTicket, bool) {
return webVNCTicket{}, false return webVNCTicket{}, false
} }
delete(webVNCTickets.items, ticket) delete(webVNCTickets.items, ticket)
return item, item.ContainerName == containerName && now.Before(item.ExpiresAt) return item, item.ContainerName == containerName &&
item.ClientIP == clientIP(r) &&
item.UserAgent == r.UserAgent() &&
now.Before(item.ExpiresAt)
} }
func cleanupExpiredWebVNCTicketsLocked(now time.Time) { func cleanupExpiredWebVNCTicketsLocked(now time.Time) {
+419 -13
View File
@@ -20,6 +20,12 @@ import (
var manager = lxc.NewManager() var manager = lxc.NewManager()
const (
clicdBackupDir = "/root/clicd-backups"
clicdNewBinaryPath = "/usr/local/bin/clicd.new"
libvirtDefaultNetworkMarker = "/var/lib/clicd/kvm/default-network.created"
)
// Run starts the CLI interface. // Run starts the CLI interface.
func Run() { func Run() {
reader := bufio.NewReader(os.Stdin) reader := bufio.NewReader(os.Stdin)
@@ -198,11 +204,18 @@ func cliCreateContainer(reader *bufio.Reader) {
container := config.FindContainerByName(name) container := config.FindContainerByName(name)
fmt.Printf("容器 %s 创建成功\n", name) fmt.Printf("容器 %s 创建成功\n", name)
if container != nil { if container != nil {
fmt.Printf("SSH: root / %s, port %d -> 22\n", container.SSHPassword, container.SSHPort) fmt.Print(formatSSHAccess(container.SSHPort))
} }
restartWebPanelForConfigChange() restartWebPanelForConfigChange()
} }
func formatSSHAccess(sshPort int) string {
if sshPort <= 0 {
return "SSH: root, 端口未分配。密码已保存,请在 Web 面板中查看或重置。\n"
}
return fmt.Sprintf("SSH: root, port %d -> 22。密码已保存,请在 Web 面板中查看或重置。\n", sshPort)
}
func cliStartContainer(reader *bufio.Reader) { func cliStartContainer(reader *bufio.Reader) {
id, name := selectContainer(reader, "开机") id, name := selectContainer(reader, "开机")
if id == 0 { if id == 0 {
@@ -532,13 +545,14 @@ func upgradeFromReleaseAsset(assetURL, latest string) error {
return err return err
} }
backupDir := "/root/clicd-backups" backupDir := clicdBackupDir
if err := os.MkdirAll(backupDir, 0700); err != nil { if err := os.MkdirAll(backupDir, 0700); err != nil {
return err return err
} }
backupPath := filepath.Join(backupDir, fmt.Sprintf("clicd.%s.%s", strings.TrimPrefix(latest, "v"), time.Now().Format("20060102-150405"))) backupName := fmt.Sprintf("clicd.%s.%s", safeReleaseBackupComponent(latest), time.Now().Format("20060102-150405"))
if _, err := os.Stat("/usr/local/bin/clicd"); err == nil { if _, err := os.Stat("/usr/local/bin/clicd"); err == nil {
if err := copyFile("/usr/local/bin/clicd", backupPath, 0755); err != nil { backupPath, err := copyFileToBackup("/usr/local/bin/clicd", backupName, 0755)
if err != nil {
return fmt.Errorf("备份旧二进制失败: %w", err) return fmt.Errorf("备份旧二进制失败: %w", err)
} }
fmt.Printf("旧版本已备份: %s\n", backupPath) fmt.Printf("旧版本已备份: %s\n", backupPath)
@@ -548,8 +562,8 @@ func upgradeFromReleaseAsset(assetURL, latest string) error {
if err := stopService("clicd"); err != nil { if err := stopService("clicd"); err != nil {
fmt.Printf("停止 Web 服务失败,继续尝试替换: %v\n", err) fmt.Printf("停止 Web 服务失败,继续尝试替换: %v\n", err)
} }
tmpBin := "/usr/local/bin/clicd.new" tmpBin := clicdNewBinaryPath
if err := copyFile(newBinary, tmpBin, 0755); err != nil { if err := copyFileToUpgradeTemp(newBinary, 0755); err != nil {
return err return err
} }
if err := os.Rename(tmpBin, "/usr/local/bin/clicd"); err != nil { if err := os.Rename(tmpBin, "/usr/local/bin/clicd"); err != nil {
@@ -614,25 +628,69 @@ func findFile(root, name string) (string, error) {
return found, nil return found, nil
} }
func copyFile(src, dst string, mode os.FileMode) error { func copyFileToBackup(src, fileName string, mode os.FileMode) (string, error) {
if fileName == "" || strings.Contains(fileName, "/") || strings.Contains(fileName, "\\") || strings.Contains(fileName, "..") {
return "", fmt.Errorf("unsafe backup file name: %s", fileName)
}
dst := filepath.Join(clicdBackupDir, fileName)
out, err := os.OpenFile(dst, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, mode)
if err != nil {
return "", err
}
if err := copyIntoOpenFile(src, out, mode); err != nil {
return "", err
}
return dst, nil
}
func copyFileToUpgradeTemp(src string, mode os.FileMode) error {
out, err := os.OpenFile(clicdNewBinaryPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, mode)
if err != nil {
return err
}
return copyIntoOpenFile(src, out, mode)
}
func copyIntoOpenFile(src string, out *os.File, mode os.FileMode) error {
in, err := os.Open(src) in, err := os.Open(src)
if err != nil { if err != nil {
out.Close()
return err return err
} }
defer in.Close() defer in.Close()
out, err := os.OpenFile(dst, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, mode) if _, err := io.Copy(out, in); err != nil {
if err != nil { out.Close()
return err return err
} }
if _, err := io.Copy(out, in); err != nil { if err := out.Chmod(mode); err != nil {
out.Close() out.Close()
return err return err
} }
if err := out.Close(); err != nil { if err := out.Close(); err != nil {
return err return err
} }
return os.Chmod(dst, mode) return nil
}
func safeReleaseBackupComponent(tag string) string {
tag = strings.TrimPrefix(strings.TrimSpace(tag), "v")
var b strings.Builder
for _, r := range tag {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '.' || r == '_' || r == '-' {
b.WriteRune(r)
continue
}
b.WriteByte('_')
}
component := strings.Trim(b.String(), "._-")
if component == "" {
return "unknown"
}
if len(component) > 64 {
return component[:64]
}
return component
} }
func sameVersion(current, latest string) bool { func sameVersion(current, latest string) bool {
@@ -681,7 +739,7 @@ func cliImportExistingContainers() {
func cliUninstall(reader *bufio.Reader) { func cliUninstall(reader *bufio.Reader) {
fmt.Println("\n--- 卸载 CLICD ---") fmt.Println("\n--- 卸载 CLICD ---")
fmt.Println("将删除 CLICD 服务和 /usr/local/bin/clicd。") fmt.Println("将删除 CLICD 服务和 /usr/local/bin/clicd。")
fmt.Println("同时会删除 /root/.clicd、/var/lib/lxc 下全部 LXC 容器,以及 /var/cache/lxc 镜像缓存。") fmt.Println("同时会删除 /root/.clicd、/var/lib/lxc、/var/lib/clicd、镜像缓存、备份、临时文件、/swapfile 和 CLICD 网络规则。")
if os.Geteuid() != 0 { if os.Geteuid() != 0 {
fmt.Println("卸载需要 root 权限。") fmt.Println("卸载需要 root 权限。")
@@ -696,6 +754,11 @@ func cliUninstall(reader *bufio.Reader) {
} }
destroyAllLXCContainers() destroyAllLXCContainers()
destroyAllKVMDomains()
removeCLICDLibvirtDefaultNetwork()
cleanupCLICDNetworking()
removeCLICDHostHooks()
removeCLICDQuotaRecords()
stopAndRemoveService() stopAndRemoveService()
removePath("/usr/local/bin/clicd") removePath("/usr/local/bin/clicd")
removePath("/etc/sysctl.d/99-clicd.conf") removePath("/etc/sysctl.d/99-clicd.conf")
@@ -703,13 +766,18 @@ func cliUninstall(reader *bufio.Reader) {
removePath("/var/log/clicd.err") removePath("/var/log/clicd.err")
removePath("/root/.clicd") removePath("/root/.clicd")
removePath("/var/lib/lxc") removePath("/var/lib/lxc")
removePath("/var/lib/clicd")
removePath("/var/cache/lxc") removePath("/var/cache/lxc")
removePath("/var/cache/clicd")
removePath("/root/clicd-backups")
removeCLICDTmpFiles()
removeCLICDSwapfile()
reloadSysctl() reloadSysctl()
fmt.Println() fmt.Println()
fmt.Println("CLICD 已卸载。") fmt.Println("CLICD 已卸载。")
fmt.Println("服务、二进制、配置、容器和 LXC 镜像缓存均已删除。") fmt.Println("服务、二进制、配置、容器/虚拟机、本地镜像、缓存、备份、临时文件和 CLICD 网络规则均已删除。")
} }
func destroyAllLXCContainers() { func destroyAllLXCContainers() {
@@ -730,6 +798,326 @@ func destroyAllLXCContainers() {
} }
} }
func destroyAllKVMDomains() {
if !commandExists("virsh") {
return
}
out, err := exec.Command("virsh", "list", "--all", "--name").Output()
if err != nil {
return
}
for _, line := range strings.Split(string(out), "\n") {
name := strings.TrimSpace(line)
if isCLICDKVMDomain(name) {
removeKVMDomain(name)
}
}
}
func isCLICDKVMDomain(name string) bool {
if !strings.HasPrefix(name, "vm-") || len(name) <= len("vm-") {
return false
}
for _, r := range strings.TrimPrefix(name, "vm-") {
if r < '0' || r > '9' {
return false
}
}
if dirExists("/var/lib/clicd/kvm/instances/" + name) {
return true
}
out, err := exec.Command("virsh", "dumpxml", name).Output()
return err == nil && strings.Contains(string(out), "/var/lib/clicd/kvm/")
}
func removeKVMDomain(name string) {
fmt.Printf("Removing KVM domain %s...\n", name)
runQuiet("virsh", "destroy", name)
if runCommandOK("virsh", "undefine", name, "--remove-all-storage", "--nvram") {
return
}
if runCommandOK("virsh", "undefine", name, "--nvram") {
return
}
runQuiet("virsh", "undefine", name)
}
func removeCLICDLibvirtDefaultNetwork() {
if !commandExists("virsh") || !fileExists(libvirtDefaultNetworkMarker) {
return
}
if libvirtDefaultUsedByNonCLICDDomain() {
fmt.Println("检测到非 CLICD 虚拟机仍在使用 libvirt default 网络,已保留 default/virbr0。")
return
}
fmt.Println("Removing CLICD-created libvirt default network...")
runQuiet("virsh", "net-destroy", "default")
runQuiet("virsh", "net-undefine", "default")
removePath(libvirtDefaultNetworkMarker)
}
func libvirtDefaultUsedByNonCLICDDomain() bool {
if !commandExists("virsh") {
return false
}
out, err := exec.Command("virsh", "list", "--all", "--name").Output()
if err != nil {
return false
}
for _, line := range strings.Split(string(out), "\n") {
name := strings.TrimSpace(line)
if name == "" || isCLICDKVMDomain(name) {
continue
}
if usesLibvirtDefaultNetwork(name) {
return true
}
}
return false
}
func usesLibvirtDefaultNetwork(domain string) bool {
out, err := exec.Command("virsh", "domiflist", domain).Output()
if err != nil {
return false
}
for _, line := range strings.Split(string(out), "\n") {
fields := strings.Fields(line)
for _, field := range fields {
if field == "default" || field == "virbr0" {
return true
}
}
}
return false
}
func cleanupCLICDNetworking() {
removeCLICDNATRules()
cleanupCLICDIPv6Runtime()
cleanupCLICDIPv6BridgeRoutes()
for _, bridge := range []string{"lxcbr0", "virbr0"} {
deleteFilterRule("FORWARD", "-i", bridge, "-j", "ACCEPT")
deleteFilterRule("FORWARD", "-o", bridge, "-j", "ACCEPT")
deleteFilterRule("FORWARD", "-i", bridge, "-o", bridge, "-j", "ACCEPT")
deleteIP6TablesBridgeRules(bridge)
}
}
func cleanupCLICDIPv6Runtime() {
if config.AppConfig == nil {
return
}
for _, c := range config.AppConfig.Containers {
cleanupCLICDContainerIPv6(c)
}
}
func cleanupCLICDContainerIPv6(c config.Container) {
bridge := "lxcbr0"
if c.IsKVM() {
bridge = "virbr0"
}
mac := strings.ToLower(strings.TrimSpace(c.MACAddress))
if mac != "" && bridge == "virbr0" {
deleteIP6FilterRule("FORWARD", "-i", bridge, "-m", "mac", "--mac-source", mac, "-j", "DROP")
}
if strings.TrimSpace(c.IPv6) == "" {
return
}
addr := strings.TrimSpace(c.IPv6)
if slash := strings.Index(addr, "/"); slash >= 0 {
addr = addr[:slash]
}
source := strings.TrimSpace(c.IPv6)
if !strings.Contains(source, "/") {
source += "/128"
}
deleteIP6NATSource(source)
deleteIP6FilterRule("FORWARD", "-i", bridge, "-s", source, "-j", "ACCEPT")
deleteIP6FilterRule("FORWARD", "-o", bridge, "-d", source, "-j", "ACCEPT")
if mac != "" && bridge == "virbr0" {
deleteIP6FilterRule("FORWARD", "-i", bridge, "-m", "mac", "--mac-source", mac, "-s", source, "-j", "ACCEPT")
deleteIP6FilterRule("FORWARD", "-i", bridge, "-m", "mac", "--mac-source", mac, "-j", "DROP")
}
runQuiet("ip", "-6", "route", "del", source, "dev", bridge)
if strings.TrimSpace(c.IPv6Interface) != "" {
runQuiet("ip", "-6", "neigh", "del", "proxy", addr, "dev", c.IPv6Interface)
}
}
func cleanupCLICDIPv6BridgeRoutes() {
if !commandExists("ip") {
return
}
for _, bridge := range []string{"lxcbr0", "virbr0"} {
out, err := exec.Command("ip", "-6", "route", "show", "dev", bridge).Output()
if err == nil {
for _, line := range strings.Split(string(out), "\n") {
fields := strings.Fields(line)
if len(fields) == 0 || !strings.HasSuffix(fields[0], "/128") {
continue
}
source := fields[0]
addr := strings.TrimSuffix(source, "/128")
deleteIP6NATSource(source)
deleteIP6FilterRule("FORWARD", "-i", bridge, "-s", source, "-j", "ACCEPT")
deleteIP6FilterRule("FORWARD", "-o", bridge, "-d", source, "-j", "ACCEPT")
removeProxyNDPForAddress(addr)
runQuiet("ip", "-6", "route", "del", source, "dev", bridge)
}
}
runQuiet("ip", "-6", "addr", "del", "fe80::1/64", "dev", bridge)
}
}
func removeProxyNDPForAddress(addr string) {
out, err := exec.Command("ip", "-6", "neigh", "show", "proxy").Output()
if err != nil {
return
}
for _, line := range strings.Split(string(out), "\n") {
fields := strings.Fields(line)
if len(fields) == 0 || fields[0] != addr {
continue
}
for i := 0; i+1 < len(fields); i++ {
if fields[i] == "dev" {
runQuiet("ip", "-6", "neigh", "del", "proxy", addr, "dev", fields[i+1])
}
}
}
}
func deleteIP6NATSource(source string) {
if !commandExists("ip6tables") || strings.TrimSpace(source) == "" {
return
}
for {
out, err := exec.Command("ip6tables", "-t", "nat", "-S", "POSTROUTING").Output()
if err != nil {
return
}
deleted := false
for _, line := range strings.Split(string(out), "\n") {
if !strings.Contains(line, "-s "+source) || !strings.Contains(line, " -j MASQUERADE") {
continue
}
fields := strings.Fields(line)
if len(fields) == 0 || fields[0] != "-A" {
continue
}
fields[0] = "-D"
args := append([]string{"-t", "nat"}, fields...)
deleted = runCommandOK("ip6tables", args...)
break
}
if !deleted {
return
}
}
}
func removeCLICDNATRules() {
if commandExists("iptables") {
for {
out, err := exec.Command("sh", "-c", "iptables -t nat -L PREROUTING -n --line-numbers 2>/dev/null | grep 'clicd-' | awk '{print $1}' | head -n 1").Output()
line := strings.TrimSpace(string(out))
if err != nil || line == "" {
break
}
if !runCommandOK("iptables", "-t", "nat", "-D", "PREROUTING", line) {
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")
}
}
func deleteNATRule(args ...string) {
fullArgs := append([]string{"-t", "nat", "-D"}, args...)
for runCommandOK("iptables", fullArgs...) {
}
}
func deleteFilterRule(args ...string) {
fullArgs := append([]string{"-D"}, args...)
for runCommandOK("iptables", fullArgs...) {
}
}
func deleteIP6FilterRule(args ...string) {
fullArgs := append([]string{"-D"}, args...)
for runCommandOK("ip6tables", fullArgs...) {
}
}
func deleteIP6TablesBridgeRules(bridge string) {
if !commandExists("ip6tables") {
return
}
for {
cmd := fmt.Sprintf("ip6tables -S FORWARD 2>/dev/null | grep -- %s | sed 's/^-A /-D /' | head -n 1", shellQuote(bridge))
out, err := exec.Command("sh", "-c", cmd).Output()
rule := strings.TrimSpace(string(out))
if err != nil || rule == "" {
return
}
if !runCommandOK("sh", "-c", "ip6tables "+rule) {
return
}
}
}
func removeCLICDHostHooks() {
runQuiet("systemctl", "stop", "clicd-kvm-ipv6.service")
runQuiet("systemctl", "disable", "clicd-kvm-ipv6.service")
runQuiet("rc-service", "clicd-kvm-ipv6", "stop")
runQuiet("rc-update", "del", "clicd-kvm-ipv6", "default")
removePath("/usr/local/sbin/clicd-kvm-ipv6-init")
removePath("/etc/systemd/system/clicd-kvm-ipv6.service")
removePath("/etc/local.d/clicd-kvm-ipv6.start")
removePath("/etc/network/if-up.d/clicd-kvm-ipv6")
}
func removeCLICDQuotaRecords() {
for _, path := range []string{"/etc/projects", "/etc/projid"} {
data, err := os.ReadFile(path)
if err != nil {
continue
}
var kept []string
for _, line := range strings.Split(string(data), "\n") {
if strings.TrimSpace(line) == "" || strings.Contains(line, "clicd-") {
continue
}
kept = append(kept, line)
}
_ = os.WriteFile(path, []byte(strings.Join(kept, "\n")+"\n"), 0644)
}
}
func removeCLICDTmpFiles() {
for _, pattern := range []string{"/tmp/clicd-*", "/tmp/clicd.*"} {
matches, _ := filepath.Glob(pattern)
for _, path := range matches {
removePath(path)
}
}
}
func removeCLICDSwapfile() {
if !fileExists("/swapfile") {
return
}
runQuiet("swapoff", "/swapfile")
removePath("/swapfile")
}
func removeLXCContainerPath(path string) { func removeLXCContainerPath(path string) {
unmountPathTree(path) unmountPathTree(path)
detachLoopDevices(path) detachLoopDevices(path)
@@ -819,6 +1207,16 @@ func removePath(path string) {
fmt.Printf("Removed %s\n", path) fmt.Printf("Removed %s\n", path)
} }
func fileExists(path string) bool {
info, err := os.Stat(path)
return err == nil && !info.IsDir()
}
func dirExists(path string) bool {
info, err := os.Stat(path)
return err == nil && info.IsDir()
}
func reloadSysctl() { func reloadSysctl() {
if commandExists("sysctl") { if commandExists("sysctl") {
runQuiet("sysctl", "--system") runQuiet("sysctl", "--system")
@@ -830,10 +1228,18 @@ func commandExists(name string) bool {
return err == nil return err == nil
} }
func runCommandOK(name string, args ...string) bool {
return exec.Command(name, args...).Run() == nil
}
func runQuiet(name string, args ...string) { func runQuiet(name string, args ...string) {
_ = exec.Command(name, args...).Run() _ = exec.Command(name, args...).Run()
} }
func shellQuote(value string) string {
return "'" + strings.ReplaceAll(value, "'", "'\"'\"'") + "'"
}
func restartWebPanelForConfigChange() { func restartWebPanelForConfigChange() {
if err := restartService("clicd"); err != nil { if err := restartService("clicd"); err != nil {
fmt.Printf("Web 面板重载跳过: %v\n", err) fmt.Printf("Web 面板重载跳过: %v\n", err)
+54
View File
@@ -0,0 +1,54 @@
package cli
import (
"strings"
"testing"
)
func TestSafeReleaseBackupComponent(t *testing.T) {
tests := map[string]string{
"v1.2.3": "1.2.3",
" release/candidate ": "release_candidate",
"../../etc/passwd": "etc_passwd",
"": "unknown",
}
for input, want := range tests {
if got := safeReleaseBackupComponent(input); got != want {
t.Fatalf("safeReleaseBackupComponent(%q) = %q, want %q", input, got, want)
}
}
}
func TestCopyFileToBackupRejectsUnsafeFileName(t *testing.T) {
unsafeNames := []string{
"../clicd",
"..\\clicd",
"subdir/clicd",
"",
}
for _, name := range unsafeNames {
if _, err := copyFileToBackup("missing-source", name, 0755); err == nil || !strings.Contains(err.Error(), "unsafe backup file name") {
t.Fatalf("copyFileToBackup(%q) error = %v, want unsafe backup file name", name, err)
}
}
}
func TestFormatSSHAccessDoesNotExposePassword(t *testing.T) {
out := formatSSHAccess(2222)
if strings.Contains(out, "/") {
t.Fatalf("formatSSHAccess output contains credential separator: %q", out)
}
if strings.Contains(strings.ToLower(out), "password123") {
t.Fatalf("formatSSHAccess output exposed password: %q", out)
}
if !strings.Contains(out, "2222 -> 22") {
t.Fatalf("formatSSHAccess output = %q, want SSH port mapping", out)
}
}
func TestFormatSSHAccessHandlesMissingPort(t *testing.T) {
out := formatSSHAccess(0)
if !strings.Contains(out, "端口未分配") {
t.Fatalf("formatSSHAccess output = %q, want missing port message", out)
}
}
+151 -86
View File
@@ -3,7 +3,6 @@ package config
import ( import (
"crypto/rand" "crypto/rand"
"encoding/hex" "encoding/hex"
"encoding/json"
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
@@ -34,6 +33,8 @@ type SavedTask struct {
TemplateID string `json:"template_id,omitempty"` TemplateID string `json:"template_id,omitempty"`
Config string `json:"config,omitempty"` Config string `json:"config,omitempty"`
User string `json:"user,omitempty"` User string `json:"user,omitempty"`
IP string `json:"ip,omitempty"`
UserAgent string `json:"user_agent,omitempty"`
} }
// SavedLoginLog for persisting login logs // SavedLoginLog for persisting login logs
@@ -108,6 +109,9 @@ type Container struct {
SnapshotScheduleLastRun string `json:"snapshot_schedule_last_run"` SnapshotScheduleLastRun string `json:"snapshot_schedule_last_run"`
SnapshotScheduleNextRun string `json:"snapshot_schedule_next_run"` SnapshotScheduleNextRun string `json:"snapshot_schedule_next_run"`
SnapshotScheduleCreatedBy string `json:"snapshot_schedule_created_by"` SnapshotScheduleCreatedBy string `json:"snapshot_schedule_created_by"`
PolicyBlocked bool `json:"policy_blocked"`
PolicyBlockedReason string `json:"policy_blocked_reason,omitempty"`
PolicyBlockedAt string `json:"policy_blocked_at,omitempty"`
} }
const ( const (
@@ -150,13 +154,18 @@ func (c *Container) VirshName() string {
// SubUser represents a sub-user with access to specific containers // SubUser represents a sub-user with access to specific containers
type ApiKeyConfig struct { type ApiKeyConfig struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
KeyHash string `json:"key_hash"` KeyHash string `json:"key_hash"`
Prefix string `json:"prefix"` Prefix string `json:"prefix"`
IPWhitelist string `json:"ip_whitelist"` IPWhitelist string `json:"ip_whitelist"`
CreatedAt string `json:"created_at"` CreatedAt string `json:"created_at"`
LastUsed string `json:"last_used"` LastUsed string `json:"last_used"`
Scopes []string `json:"scopes,omitempty"`
ExpiresAt string `json:"expires_at,omitempty"`
Disabled bool `json:"disabled,omitempty"`
ContainerUUIDs []string `json:"container_uuids,omitempty"`
LastUsedIP string `json:"last_used_ip,omitempty"`
} }
// DeleteApiKey removes an API key by ID // DeleteApiKey removes an API key by ID
@@ -198,23 +207,24 @@ type Snapshot struct {
// ClicdConfig is the main configuration structure // ClicdConfig is the main configuration structure
type ClicdConfig struct { type ClicdConfig struct {
AdminUser string `json:"admin_user"` AdminUser string `json:"admin_user"`
AdminPassHash string `json:"admin_pass_hash"` AdminPassHash string `json:"admin_pass_hash"`
JWTSecret string `json:"jwt_secret"` JWTSecret string `json:"jwt_secret"`
Port int `json:"port"` Port int `json:"port"`
DataDir string `json:"data_dir"` DataDir string `json:"data_dir"`
Containers []Container `json:"containers"` Containers []Container `json:"containers"`
NextContainerID int `json:"next_container_id"` NextContainerID int `json:"next_container_id"`
NextVNCPort int `json:"next_vnc_port"` NextVNCPort int `json:"next_vnc_port"`
NextSSHPort int `json:"next_ssh_port"` NextSSHPort int `json:"next_ssh_port"`
SetupComplete bool `json:"setup_complete"` SetupComplete bool `json:"setup_complete"`
SubUsers []SubUser `json:"sub_users"` SubUsers []SubUser `json:"sub_users"`
ApiKeys []ApiKeyConfig `json:"api_keys"` ApiKeys []ApiKeyConfig `json:"api_keys"`
AuditLogs []AuditLog `json:"audit_logs"` AuditLogs []AuditLog `json:"audit_logs"`
Tasks []SavedTask `json:"tasks"` Tasks []SavedTask `json:"tasks"`
LoginLogs []SavedLoginLog `json:"login_logs"` LoginLogs []SavedLoginLog `json:"login_logs"`
EnabledImages []string `json:"enabled_images"` EnabledImages []string `json:"enabled_images"`
Snapshots []Snapshot `json:"snapshots"` Snapshots []Snapshot `json:"snapshots"`
SecurityAutoShutdown bool `json:"security_auto_shutdown"`
} }
var configPath string var configPath string
@@ -277,69 +287,91 @@ func InitConfig() (*ClicdConfig, error) {
dataDir := getDataDir() dataDir := getDataDir()
if err := os.MkdirAll(filepath.Dir(cfgPath), 0700); err != nil { if err := os.MkdirAll(filepath.Dir(cfgPath), 0700); err != nil {
return nil, fmt.Errorf("failed to create config directory: %v", err) return nil, fmt.Errorf("failed to create data directory: %v", err)
} }
if err := os.MkdirAll(dataDir, 0700); err != nil { if err := os.MkdirAll(dataDir, 0700); err != nil {
return nil, fmt.Errorf("failed to create data directory: %v", err) return nil, fmt.Errorf("failed to create data directory: %v", err)
} }
if err := openConfigDB(); err != nil {
return nil, err
}
if _, err := os.Stat(cfgPath); os.IsNotExist(err) { cfg, ok, err := loadConfigFromDB()
// First run: generate new config if err != nil {
adminUser := "admin" return nil, err
adminPass := generateRandomString(16) }
jwtSecret := generateRandomString(32) if ok {
hash, err := bcrypt.GenerateFromPassword([]byte(adminPass), bcrypt.DefaultCost) AppConfig = cfg
if err != nil { normalizeConfigDefaults(dataDir)
return nil, fmt.Errorf("failed to hash password: %v", err) if migrateLoadedConfig() {
if err := SaveConfig(); err != nil {
return nil, err
}
} }
AppConfig = &ClicdConfig{
AdminUser: adminUser,
AdminPassHash: string(hash),
JWTSecret: jwtSecret,
Port: 8999,
DataDir: dataDir,
Containers: []Container{},
NextContainerID: 1,
NextVNCPort: 5900,
NextSSHPort: 22000,
SetupComplete: false,
SubUsers: []SubUser{},
AuditLogs: []AuditLog{},
Tasks: []SavedTask{},
LoginLogs: []SavedLoginLog{},
Snapshots: []Snapshot{},
}
if err := SaveConfig(); err != nil {
return nil, err
}
fmt.Println("\n========================================")
fmt.Println(" CLICD - LXC Container Manager")
fmt.Println("========================================")
fmt.Printf(" Username: %s\n", adminUser)
fmt.Printf(" Password: %s\n", adminPass)
fmt.Println("========================================")
fmt.Println(" Please save these credentials!")
fmt.Println(" Web Interface: http://0.0.0.0:8999")
fmt.Println("========================================")
fmt.Println()
return AppConfig, nil return AppConfig, nil
} }
// Load existing config legacy, ok, err := loadLegacyJSONConfig(cfgPath)
data, err := os.ReadFile(cfgPath)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to read config: %v", err) return nil, err
}
if ok {
AppConfig = legacy
normalizeConfigDefaults(dataDir)
if migrateLoadedConfig() {
// Save below persists normalized legacy data into SQLite.
}
if err := SaveConfig(); err != nil {
return nil, err
}
return AppConfig, nil
} }
AppConfig = &ClicdConfig{} adminUser := "admin"
if err := json.Unmarshal(data, AppConfig); err != nil { adminPass := generateRandomString(16)
return nil, fmt.Errorf("failed to parse config: %v", err) jwtSecret := generateRandomString(32)
hash, err := bcrypt.GenerateFromPassword([]byte(adminPass), bcrypt.DefaultCost)
if err != nil {
return nil, fmt.Errorf("failed to hash password: %v", err)
} }
AppConfig = &ClicdConfig{
AdminUser: adminUser,
AdminPassHash: string(hash),
JWTSecret: jwtSecret,
Port: 8999,
DataDir: dataDir,
Containers: []Container{},
NextContainerID: 1,
NextVNCPort: 5900,
NextSSHPort: 22000,
SetupComplete: false,
SubUsers: []SubUser{},
AuditLogs: []AuditLog{},
Tasks: []SavedTask{},
LoginLogs: []SavedLoginLog{},
Snapshots: []Snapshot{},
}
if err := SaveConfig(); err != nil {
return nil, err
}
fmt.Println("\n========================================")
fmt.Println(" CLICD - LXC Container Manager")
fmt.Println("========================================")
fmt.Printf(" Username: %s\n", adminUser)
fmt.Printf(" Password: %s\n", adminPass)
fmt.Println("========================================")
fmt.Println(" Please save these credentials!")
fmt.Println(" Web Interface: http://0.0.0.0:8999")
fmt.Println("========================================")
fmt.Println()
return AppConfig, nil
}
func normalizeConfigDefaults(dataDir string) {
if AppConfig.Port == 0 { if AppConfig.Port == 0 {
AppConfig.Port = 8999 AppConfig.Port = 8999
} }
@@ -361,6 +393,33 @@ func InitConfig() (*ClicdConfig, error) {
if AppConfig.Snapshots == nil { if AppConfig.Snapshots == nil {
AppConfig.Snapshots = make([]Snapshot, 0) AppConfig.Snapshots = make([]Snapshot, 0)
} }
if AppConfig.SubUsers == nil {
AppConfig.SubUsers = make([]SubUser, 0)
}
if AppConfig.ApiKeys == nil {
AppConfig.ApiKeys = make([]ApiKeyConfig, 0)
} else {
for i := range AppConfig.ApiKeys {
if len(AppConfig.ApiKeys[i].Scopes) == 0 {
AppConfig.ApiKeys[i].Scopes = []string{"*"}
}
}
}
if AppConfig.AuditLogs == nil {
AppConfig.AuditLogs = make([]AuditLog, 0)
}
if AppConfig.Tasks == nil {
AppConfig.Tasks = make([]SavedTask, 0)
}
if AppConfig.LoginLogs == nil {
AppConfig.LoginLogs = make([]SavedLoginLog, 0)
}
if AppConfig.EnabledImages == nil {
AppConfig.EnabledImages = make([]string, 0)
}
}
func migrateLoadedConfig() bool {
changed := ensureContainerUUIDs() changed := ensureContainerUUIDs()
if ensureContainerVirtualization() { if ensureContainerVirtualization() {
changed = true changed = true
@@ -380,13 +439,7 @@ func InitConfig() (*ClicdConfig, error) {
if removeLegacyVNCMappings() { if removeLegacyVNCMappings() {
changed = true changed = true
} }
if changed { return changed
if err := SaveConfig(); err != nil {
return nil, err
}
}
return AppConfig, nil
} }
func ensureContainerVirtualization() bool { func ensureContainerVirtualization() bool {
@@ -537,11 +590,7 @@ func removeLegacyVNCMappings() bool {
// SaveConfig saves configuration to disk // SaveConfig saves configuration to disk
func SaveConfig() error { func SaveConfig() error {
data, err := json.MarshalIndent(AppConfig, "", " ") return saveConfigToDB()
if err != nil {
return fmt.Errorf("failed to marshal config: %v", err)
}
return os.WriteFile(getConfigPath(), data, 0600)
} }
// AddContainer adds a container to the config // AddContainer adds a container to the config
@@ -717,6 +766,22 @@ func UpdateContainerStatus(id int, status string) {
} }
} }
func SetContainerPolicyBlock(id int, blocked bool, reason string) {
c := FindContainer(id)
if c == nil {
return
}
c.PolicyBlocked = blocked
if blocked {
c.PolicyBlockedReason = reason
c.PolicyBlockedAt = time.Now().Format("2006-01-02 15:04:05")
} else {
c.PolicyBlockedReason = ""
c.PolicyBlockedAt = ""
}
SaveConfig()
}
// UpdateVNC refreshes all container statuses // UpdateVNC refreshes all container statuses
func UpdateVNC(containers []Container) { func UpdateVNC(containers []Container) {
AppConfig.Containers = containers AppConfig.Containers = containers
+952
View File
@@ -0,0 +1,952 @@
package config
import (
"database/sql"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
_ "modernc.org/sqlite"
)
var (
dbMu sync.Mutex
db *sql.DB
)
type savedTaskConfig struct {
Name string `json:"name"`
Virtualization string `json:"virtualization,omitempty"`
TemplateID string `json:"template_id"`
VCPU float64 `json:"vcpu"`
CPUPercent int `json:"cpu_percent"`
RAMMB int `json:"ram_mb"`
DiskGB int `json:"disk_gb"`
NetworkBWMbps int `json:"network_bw_mbps"`
MonthlyTrafficGB int `json:"monthly_traffic_gb"`
TrafficMode string `json:"traffic_mode"`
TrafficInGB int `json:"traffic_in_gb"`
TrafficOutGB int `json:"traffic_out_gb"`
IOSpeedMBps int `json:"io_speed_mbps"`
ExtraPorts []int `json:"extra_ports"`
PortMappingCount int `json:"port_mapping_count"`
SnapshotLimit int `json:"snapshot_limit"`
AssignIPv6 bool `json:"assign_ipv6"`
ExpiresAt string `json:"expires_at"`
}
func parseSavedTaskConfig(raw string) savedTaskConfig {
if raw == "" {
return savedTaskConfig{}
}
var cfg savedTaskConfig
_ = json.Unmarshal([]byte(raw), &cfg)
return cfg
}
func encodeSavedTaskConfig(cfg savedTaskConfig) string {
data, err := json.Marshal(cfg)
if err != nil {
return ""
}
return string(data)
}
func encodeStringSlice(values []string) string {
if len(values) == 0 {
return ""
}
data, err := json.Marshal(values)
if err != nil {
return ""
}
return string(data)
}
func decodeStringSlice(raw string) []string {
if strings.TrimSpace(raw) == "" {
return nil
}
var values []string
if err := json.Unmarshal([]byte(raw), &values); err != nil {
return nil
}
return values
}
func getDBPath() string {
cfgPath := getConfigPath()
ext := filepath.Ext(cfgPath)
if ext == "" {
return cfgPath + ".db"
}
return strings.TrimSuffix(cfgPath, ext) + ".db"
}
func openConfigDB() error {
if db != nil {
return nil
}
dbPath := getDBPath()
if err := os.MkdirAll(filepath.Dir(dbPath), 0700); err != nil {
return fmt.Errorf("failed to create database directory: %v", err)
}
next, err := sql.Open("sqlite", dbPath)
if err != nil {
return fmt.Errorf("failed to open sqlite database: %v", err)
}
next.SetMaxOpenConns(1)
next.SetMaxIdleConns(1)
for _, stmt := range []string{
"PRAGMA journal_mode=WAL",
"PRAGMA synchronous=NORMAL",
"PRAGMA busy_timeout=5000",
"PRAGMA foreign_keys=ON",
} {
if _, err := next.Exec(stmt); err != nil {
_ = next.Close()
return fmt.Errorf("failed to initialize sqlite pragma: %v", err)
}
}
db = next
return ensureSchema()
}
func ensureSchema() error {
stmts := []string{
`CREATE TABLE IF NOT EXISTS app_meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
)`,
`CREATE TABLE IF NOT EXISTS containers (
id INTEGER PRIMARY KEY,
uuid TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
virtualization TEXT,
lxc_name TEXT,
kvm_name TEXT,
disk_image TEXT,
mac_address TEXT,
template TEXT,
vcpu REAL,
ram_mb INTEGER,
disk_gb INTEGER,
network_bw_mbps INTEGER,
monthly_traffic_gb INTEGER,
traffic_mode TEXT,
traffic_in_gb INTEGER,
traffic_out_gb INTEGER,
traffic_used_rx INTEGER,
traffic_used_tx INTEGER,
traffic_reset_date TEXT,
io_speed_mbps INTEGER,
status TEXT,
ip TEXT,
ipv6 TEXT,
ipv6_prefix_len INTEGER,
ipv6_interface TEXT,
vnc_port INTEGER,
ssh_port INTEGER,
ssh_password TEXT,
ssh_host_key TEXT,
port_mapping_limit INTEGER,
snapshot_limit INTEGER,
created_at TEXT,
expires_at TEXT,
snapshot_schedule_enabled INTEGER,
snapshot_schedule_interval_hours INTEGER,
snapshot_schedule_time TEXT,
snapshot_schedule_last_run TEXT,
snapshot_schedule_next_run TEXT,
snapshot_schedule_created_by TEXT,
policy_blocked INTEGER,
policy_blocked_reason TEXT,
policy_blocked_at TEXT
)`,
`CREATE TABLE IF NOT EXISTS port_mappings (
container_id INTEGER NOT NULL,
position INTEGER NOT NULL,
container_port INTEGER NOT NULL,
host_port INTEGER NOT NULL,
protocol TEXT,
description TEXT,
PRIMARY KEY (container_id, position)
)`,
`CREATE TABLE IF NOT EXISTS sub_users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL,
password TEXT,
pass_hash TEXT,
access_code TEXT,
created_at TEXT,
token_version INTEGER
)`,
`CREATE TABLE IF NOT EXISTS sub_user_container_names (
sub_user_id TEXT NOT NULL,
position INTEGER NOT NULL,
container_name TEXT NOT NULL,
PRIMARY KEY (sub_user_id, position)
)`,
`CREATE TABLE IF NOT EXISTS sub_user_container_uuids (
sub_user_id TEXT NOT NULL,
position INTEGER NOT NULL,
container_uuid TEXT NOT NULL,
PRIMARY KEY (sub_user_id, position)
)`,
`CREATE TABLE IF NOT EXISTS api_keys (
id TEXT PRIMARY KEY,
name TEXT,
key_hash TEXT,
prefix TEXT,
ip_whitelist TEXT,
created_at TEXT,
last_used TEXT,
scopes TEXT,
expires_at TEXT,
disabled INTEGER,
container_uuids TEXT,
last_used_ip TEXT
)`,
`CREATE TABLE IF NOT EXISTS audit_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
time TEXT,
action TEXT,
target TEXT,
detail TEXT,
user TEXT,
ip TEXT,
user_agent TEXT,
success_set INTEGER,
success INTEGER,
error TEXT
)`,
`CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY,
type TEXT,
container_id INTEGER,
container_name TEXT,
status TEXT,
error TEXT,
created_at TEXT,
template_id TEXT,
user TEXT,
ip TEXT,
user_agent TEXT,
cfg_name TEXT,
cfg_virtualization TEXT,
cfg_template_id TEXT,
cfg_vcpu REAL,
cfg_cpu_percent INTEGER,
cfg_ram_mb INTEGER,
cfg_disk_gb INTEGER,
cfg_network_bw_mbps INTEGER,
cfg_monthly_traffic_gb INTEGER,
cfg_traffic_mode TEXT,
cfg_traffic_in_gb INTEGER,
cfg_traffic_out_gb INTEGER,
cfg_io_speed_mbps INTEGER,
cfg_port_mapping_count INTEGER,
cfg_snapshot_limit INTEGER,
cfg_assign_ipv6 INTEGER,
cfg_expires_at TEXT
)`,
`CREATE TABLE IF NOT EXISTS task_extra_ports (
task_id TEXT NOT NULL,
position INTEGER NOT NULL,
port INTEGER NOT NULL,
PRIMARY KEY (task_id, position)
)`,
`CREATE TABLE IF NOT EXISTS login_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
time TEXT,
username TEXT,
ip TEXT,
user_agent TEXT,
success INTEGER
)`,
`CREATE TABLE IF NOT EXISTS enabled_images (
position INTEGER PRIMARY KEY,
image_id TEXT NOT NULL
)`,
`CREATE TABLE IF NOT EXISTS snapshots (
id TEXT PRIMARY KEY,
container_id INTEGER,
container_name TEXT,
lxc_name TEXT,
created_at TEXT,
created_by TEXT,
scheduled INTEGER,
path TEXT,
size_bytes INTEGER
)`,
}
for _, stmt := range stmts {
if _, err := db.Exec(stmt); err != nil {
return fmt.Errorf("failed to create sqlite schema: %v", err)
}
}
return ensureSchemaMigrations()
}
func ensureSchemaMigrations() error {
for _, column := range []struct {
table string
name string
def string
}{
{"api_keys", "scopes", "TEXT"},
{"api_keys", "expires_at", "TEXT"},
{"api_keys", "disabled", "INTEGER"},
{"api_keys", "container_uuids", "TEXT"},
{"api_keys", "last_used_ip", "TEXT"},
{"tasks", "ip", "TEXT"},
{"tasks", "user_agent", "TEXT"},
} {
if err := ensureColumn(column.table, column.name, column.def); err != nil {
return err
}
}
return nil
}
func ensureColumn(table, name, def string) error {
rows, err := db.Query("PRAGMA table_info(" + table + ")")
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var cid int
var columnName, columnType string
var notNull, pk int
var defaultValue interface{}
if err := rows.Scan(&cid, &columnName, &columnType, &notNull, &defaultValue, &pk); err != nil {
return err
}
if columnName == name {
return nil
}
}
if err := rows.Err(); err != nil {
return err
}
_, err = db.Exec("ALTER TABLE " + table + " ADD COLUMN " + name + " " + def)
return err
}
func loadConfigFromDB() (*ClicdConfig, bool, error) {
meta := map[string]string{}
rows, err := db.Query("SELECT key, value FROM app_meta")
if err != nil {
return nil, false, err
}
defer rows.Close()
for rows.Next() {
var k, v string
if err := rows.Scan(&k, &v); err != nil {
return nil, false, err
}
meta[k] = v
}
if err := rows.Err(); err != nil {
return nil, false, err
}
if meta["admin_user"] == "" {
return nil, false, nil
}
cfg := &ClicdConfig{
AdminUser: meta["admin_user"],
AdminPassHash: meta["admin_pass_hash"],
JWTSecret: meta["jwt_secret"],
Port: atoi(meta["port"]),
DataDir: meta["data_dir"],
NextContainerID: atoi(meta["next_container_id"]),
NextVNCPort: atoi(meta["next_vnc_port"]),
NextSSHPort: atoi(meta["next_ssh_port"]),
SetupComplete: atob(meta["setup_complete"]),
SecurityAutoShutdown: atob(meta["security_auto_shutdown"]),
}
if cfg.Containers, err = loadContainers(); err != nil {
return nil, false, err
}
if cfg.SubUsers, err = loadSubUsers(); err != nil {
return nil, false, err
}
if cfg.ApiKeys, err = loadAPIKeys(); err != nil {
return nil, false, err
}
if cfg.AuditLogs, err = loadAuditLogs(); err != nil {
return nil, false, err
}
if cfg.Tasks, err = loadTasks(); err != nil {
return nil, false, err
}
if cfg.LoginLogs, err = loadLoginLogs(); err != nil {
return nil, false, err
}
if cfg.EnabledImages, err = loadEnabledImages(); err != nil {
return nil, false, err
}
if cfg.Snapshots, err = loadSnapshots(); err != nil {
return nil, false, err
}
return cfg, true, nil
}
func saveConfigToDB() error {
if db == nil {
return fmt.Errorf("sqlite database is not initialized")
}
dbMu.Lock()
defer dbMu.Unlock()
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
for _, table := range []string{
"port_mappings",
"sub_user_container_names",
"sub_user_container_uuids",
"containers",
"sub_users",
"api_keys",
"audit_logs",
"task_extra_ports",
"tasks",
"login_logs",
"enabled_images",
"snapshots",
"app_meta",
} {
if _, err := tx.Exec("DELETE FROM " + table); err != nil {
return err
}
}
if err := saveMeta(tx); err != nil {
return err
}
if err := saveContainers(tx); err != nil {
return err
}
if err := saveSubUsers(tx); err != nil {
return err
}
if err := saveAPIKeys(tx); err != nil {
return err
}
if err := saveAuditLogs(tx); err != nil {
return err
}
if err := saveTasksDB(tx); err != nil {
return err
}
if err := saveLoginLogs(tx); err != nil {
return err
}
if err := saveEnabledImages(tx); err != nil {
return err
}
if err := saveSnapshots(tx); err != nil {
return err
}
return tx.Commit()
}
func saveMeta(tx *sql.Tx) error {
values := map[string]string{
"admin_user": AppConfig.AdminUser,
"admin_pass_hash": AppConfig.AdminPassHash,
"jwt_secret": AppConfig.JWTSecret,
"port": strconv.Itoa(AppConfig.Port),
"data_dir": AppConfig.DataDir,
"next_container_id": strconv.Itoa(AppConfig.NextContainerID),
"next_vnc_port": strconv.Itoa(AppConfig.NextVNCPort),
"next_ssh_port": strconv.Itoa(AppConfig.NextSSHPort),
"setup_complete": btoa(AppConfig.SetupComplete),
"security_auto_shutdown": btoa(AppConfig.SecurityAutoShutdown),
"schema_version": "1",
"updated_at": time.Now().Format("2006-01-02 15:04:05"),
}
for k, v := range values {
if _, err := tx.Exec("INSERT INTO app_meta(key, value) VALUES (?, ?)", k, v); err != nil {
return err
}
}
return nil
}
func saveContainers(tx *sql.Tx) error {
for _, c := range AppConfig.Containers {
if _, err := tx.Exec(`INSERT INTO containers (
id, uuid, name, virtualization, lxc_name, kvm_name, disk_image, mac_address, template,
vcpu, ram_mb, disk_gb, network_bw_mbps, monthly_traffic_gb, traffic_mode, traffic_in_gb,
traffic_out_gb, traffic_used_rx, traffic_used_tx, traffic_reset_date, io_speed_mbps,
status, ip, ipv6, ipv6_prefix_len, ipv6_interface, vnc_port, ssh_port, ssh_password,
ssh_host_key, port_mapping_limit, snapshot_limit, created_at, expires_at,
snapshot_schedule_enabled, snapshot_schedule_interval_hours, snapshot_schedule_time,
snapshot_schedule_last_run, snapshot_schedule_next_run, snapshot_schedule_created_by,
policy_blocked, policy_blocked_reason, policy_blocked_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
c.ID, c.UUID, c.Name, c.Virtualization, c.LXCName, c.KVMName, c.DiskImage, c.MACAddress, c.Template,
c.VCPU, c.RAMMB, c.DiskGB, c.NetworkBWMbps, c.MonthlyTrafficGB, c.TrafficMode, c.TrafficInGB,
c.TrafficOutGB, c.TrafficUsedRX, c.TrafficUsedTX, c.TrafficResetDate, c.IOSpeedMBps,
c.Status, c.IP, c.IPv6, c.IPv6PrefixLen, c.IPv6Interface, c.VNCPort, c.SSHPort, c.SSHPassword,
c.SSHHostKey, c.PortMappingLimit, c.SnapshotLimit, c.CreatedAt, c.ExpiresAt,
boolInt(c.SnapshotScheduleEnabled), c.SnapshotScheduleIntervalHours, c.SnapshotScheduleTime,
c.SnapshotScheduleLastRun, c.SnapshotScheduleNextRun, c.SnapshotScheduleCreatedBy,
boolInt(c.PolicyBlocked), c.PolicyBlockedReason, c.PolicyBlockedAt,
); err != nil {
return err
}
for i, pm := range c.PortMappings {
if _, err := tx.Exec(`INSERT INTO port_mappings(container_id, position, container_port, host_port, protocol, description)
VALUES (?, ?, ?, ?, ?, ?)`, c.ID, i, pm.ContainerPort, pm.HostPort, pm.Protocol, pm.Description); err != nil {
return err
}
}
}
return nil
}
func saveSubUsers(tx *sql.Tx) error {
for _, su := range AppConfig.SubUsers {
if _, err := tx.Exec(`INSERT INTO sub_users(id, username, password, pass_hash, access_code, created_at, token_version)
VALUES (?, ?, ?, ?, ?, ?, ?)`, su.ID, su.Username, su.Password, su.PassHash, su.AccessCode, su.CreatedAt, su.TokenVersion); err != nil {
return err
}
for i, name := range su.ContainerNames {
if _, err := tx.Exec(`INSERT INTO sub_user_container_names(sub_user_id, position, container_name) VALUES (?, ?, ?)`, su.ID, i, name); err != nil {
return err
}
}
for i, uuid := range su.ContainerUUIDs {
if _, err := tx.Exec(`INSERT INTO sub_user_container_uuids(sub_user_id, position, container_uuid) VALUES (?, ?, ?)`, su.ID, i, uuid); err != nil {
return err
}
}
}
return nil
}
func saveAPIKeys(tx *sql.Tx) error {
for _, k := range AppConfig.ApiKeys {
scopes := encodeStringSlice(k.Scopes)
containerUUIDs := encodeStringSlice(k.ContainerUUIDs)
if _, err := tx.Exec(`INSERT INTO api_keys(id, name, key_hash, prefix, ip_whitelist, created_at, last_used, scopes, expires_at, disabled, container_uuids, last_used_ip)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, k.ID, k.Name, k.KeyHash, k.Prefix, k.IPWhitelist, k.CreatedAt, k.LastUsed, scopes, k.ExpiresAt, boolInt(k.Disabled), containerUUIDs, k.LastUsedIP); err != nil {
return err
}
}
return nil
}
func saveAuditLogs(tx *sql.Tx) error {
for _, log := range AppConfig.AuditLogs {
successSet := 0
success := 0
if log.Success != nil {
successSet = 1
if *log.Success {
success = 1
}
}
if _, err := tx.Exec(`INSERT INTO audit_logs(time, action, target, detail, user, ip, user_agent, success_set, success, error)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, log.Time, log.Action, log.Target, log.Detail, log.User, log.IP, log.UserAgent, successSet, success, log.Error); err != nil {
return err
}
}
return nil
}
func saveTasksDB(tx *sql.Tx) error {
for _, task := range AppConfig.Tasks {
cfg := parseSavedTaskConfig(task.Config)
if _, err := tx.Exec(`INSERT INTO tasks(
id, type, container_id, container_name, status, error, created_at, template_id, user, ip, user_agent,
cfg_name, cfg_virtualization, cfg_template_id, cfg_vcpu, cfg_cpu_percent, cfg_ram_mb, cfg_disk_gb,
cfg_network_bw_mbps, cfg_monthly_traffic_gb, cfg_traffic_mode, cfg_traffic_in_gb,
cfg_traffic_out_gb, cfg_io_speed_mbps, cfg_port_mapping_count, cfg_snapshot_limit,
cfg_assign_ipv6, cfg_expires_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
task.ID, task.Type, task.ContainerID, task.ContainerName, task.Status, task.Error, task.CreatedAt, task.TemplateID, task.User, task.IP, task.UserAgent,
cfg.Name, cfg.Virtualization, cfg.TemplateID, cfg.VCPU, cfg.CPUPercent, cfg.RAMMB, cfg.DiskGB,
cfg.NetworkBWMbps, cfg.MonthlyTrafficGB, cfg.TrafficMode, cfg.TrafficInGB,
cfg.TrafficOutGB, cfg.IOSpeedMBps, cfg.PortMappingCount, cfg.SnapshotLimit,
boolInt(cfg.AssignIPv6), cfg.ExpiresAt,
); err != nil {
return err
}
for i, port := range cfg.ExtraPorts {
if _, err := tx.Exec(`INSERT INTO task_extra_ports(task_id, position, port) VALUES (?, ?, ?)`, task.ID, i, port); err != nil {
return err
}
}
}
return nil
}
func saveLoginLogs(tx *sql.Tx) error {
for _, log := range AppConfig.LoginLogs {
if _, err := tx.Exec(`INSERT INTO login_logs(time, username, ip, user_agent, success) VALUES (?, ?, ?, ?, ?)`,
log.Time, log.Username, log.IP, log.UserAgent, boolInt(log.Success)); err != nil {
return err
}
}
return nil
}
func saveEnabledImages(tx *sql.Tx) error {
for i, id := range AppConfig.EnabledImages {
if _, err := tx.Exec(`INSERT INTO enabled_images(position, image_id) VALUES (?, ?)`, i, id); err != nil {
return err
}
}
return nil
}
func saveSnapshots(tx *sql.Tx) error {
for _, snapshot := range AppConfig.Snapshots {
if _, err := tx.Exec(`INSERT INTO snapshots(id, container_id, container_name, lxc_name, created_at, created_by, scheduled, path, size_bytes)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, snapshot.ID, snapshot.ContainerID, snapshot.ContainerName, snapshot.LXCName, snapshot.CreatedAt, snapshot.CreatedBy, boolInt(snapshot.Scheduled), snapshot.Path, snapshot.SizeBytes); err != nil {
return err
}
}
return nil
}
func loadContainers() ([]Container, error) {
rows, err := db.Query(`SELECT
id, uuid, name, virtualization, lxc_name, kvm_name, disk_image, mac_address, template,
vcpu, ram_mb, disk_gb, network_bw_mbps, monthly_traffic_gb, traffic_mode, traffic_in_gb,
traffic_out_gb, traffic_used_rx, traffic_used_tx, traffic_reset_date, io_speed_mbps,
status, ip, ipv6, ipv6_prefix_len, ipv6_interface, vnc_port, ssh_port, ssh_password,
ssh_host_key, port_mapping_limit, snapshot_limit, created_at, expires_at,
snapshot_schedule_enabled, snapshot_schedule_interval_hours, snapshot_schedule_time,
snapshot_schedule_last_run, snapshot_schedule_next_run, snapshot_schedule_created_by,
policy_blocked, policy_blocked_reason, policy_blocked_at
FROM containers ORDER BY id`)
if err != nil {
return nil, err
}
defer rows.Close()
result := []Container{}
for rows.Next() {
var c Container
var scheduleEnabled, policyBlocked int
if err := rows.Scan(
&c.ID, &c.UUID, &c.Name, &c.Virtualization, &c.LXCName, &c.KVMName, &c.DiskImage, &c.MACAddress, &c.Template,
&c.VCPU, &c.RAMMB, &c.DiskGB, &c.NetworkBWMbps, &c.MonthlyTrafficGB, &c.TrafficMode, &c.TrafficInGB,
&c.TrafficOutGB, &c.TrafficUsedRX, &c.TrafficUsedTX, &c.TrafficResetDate, &c.IOSpeedMBps,
&c.Status, &c.IP, &c.IPv6, &c.IPv6PrefixLen, &c.IPv6Interface, &c.VNCPort, &c.SSHPort, &c.SSHPassword,
&c.SSHHostKey, &c.PortMappingLimit, &c.SnapshotLimit, &c.CreatedAt, &c.ExpiresAt,
&scheduleEnabled, &c.SnapshotScheduleIntervalHours, &c.SnapshotScheduleTime,
&c.SnapshotScheduleLastRun, &c.SnapshotScheduleNextRun, &c.SnapshotScheduleCreatedBy,
&policyBlocked, &c.PolicyBlockedReason, &c.PolicyBlockedAt,
); err != nil {
return nil, err
}
c.SnapshotScheduleEnabled = scheduleEnabled != 0
c.PolicyBlocked = policyBlocked != 0
result = append(result, c)
}
if err := rows.Err(); err != nil {
return nil, err
}
if err := rows.Close(); err != nil {
return nil, err
}
for i := range result {
result[i].PortMappings, err = loadPortMappings(result[i].ID)
if err != nil {
return nil, err
}
}
return result, nil
}
func loadPortMappings(containerID int) ([]PortMapping, error) {
rows, err := db.Query(`SELECT container_port, host_port, protocol, description FROM port_mappings WHERE container_id = ? ORDER BY position`, containerID)
if err != nil {
return nil, err
}
defer rows.Close()
result := []PortMapping{}
for rows.Next() {
var pm PortMapping
if err := rows.Scan(&pm.ContainerPort, &pm.HostPort, &pm.Protocol, &pm.Description); err != nil {
return nil, err
}
result = append(result, pm)
}
return result, rows.Err()
}
func loadSubUsers() ([]SubUser, error) {
rows, err := db.Query(`SELECT id, username, password, pass_hash, access_code, created_at, token_version FROM sub_users ORDER BY created_at, id`)
if err != nil {
return nil, err
}
defer rows.Close()
result := []SubUser{}
for rows.Next() {
var su SubUser
if err := rows.Scan(&su.ID, &su.Username, &su.Password, &su.PassHash, &su.AccessCode, &su.CreatedAt, &su.TokenVersion); err != nil {
return nil, err
}
result = append(result, su)
}
if err := rows.Err(); err != nil {
return nil, err
}
if err := rows.Close(); err != nil {
return nil, err
}
for i := range result {
result[i].ContainerNames, err = loadStringList("sub_user_container_names", "container_name", "sub_user_id", result[i].ID)
if err != nil {
return nil, err
}
result[i].ContainerUUIDs, err = loadStringList("sub_user_container_uuids", "container_uuid", "sub_user_id", result[i].ID)
if err != nil {
return nil, err
}
}
return result, nil
}
func loadStringList(table, valueColumn, keyColumn, key string) ([]string, error) {
rows, err := db.Query(fmt.Sprintf(`SELECT %s FROM %s WHERE %s = ? ORDER BY position`, valueColumn, table, keyColumn), key)
if err != nil {
return nil, err
}
defer rows.Close()
values := []string{}
for rows.Next() {
var value string
if err := rows.Scan(&value); err != nil {
return nil, err
}
values = append(values, value)
}
return values, rows.Err()
}
func loadAPIKeys() ([]ApiKeyConfig, error) {
rows, err := db.Query(`SELECT id, name, key_hash, prefix, ip_whitelist, created_at, last_used, scopes, expires_at, disabled, container_uuids, last_used_ip FROM api_keys ORDER BY created_at, id`)
if err != nil {
return nil, err
}
defer rows.Close()
result := []ApiKeyConfig{}
for rows.Next() {
var k ApiKeyConfig
var scopes, expiresAt, containerUUIDs, lastUsedIP sql.NullString
var disabled sql.NullInt64
if err := rows.Scan(&k.ID, &k.Name, &k.KeyHash, &k.Prefix, &k.IPWhitelist, &k.CreatedAt, &k.LastUsed, &scopes, &expiresAt, &disabled, &containerUUIDs, &lastUsedIP); err != nil {
return nil, err
}
k.Scopes = decodeStringSlice(scopes.String)
k.ExpiresAt = expiresAt.String
k.Disabled = disabled.Valid && disabled.Int64 != 0
k.ContainerUUIDs = decodeStringSlice(containerUUIDs.String)
k.LastUsedIP = lastUsedIP.String
result = append(result, k)
}
return result, rows.Err()
}
func loadAuditLogs() ([]AuditLog, error) {
rows, err := db.Query(`SELECT time, action, target, detail, user, ip, user_agent, success_set, success, error FROM audit_logs ORDER BY id`)
if err != nil {
return nil, err
}
defer rows.Close()
result := []AuditLog{}
for rows.Next() {
var log AuditLog
var successSet, success int
if err := rows.Scan(&log.Time, &log.Action, &log.Target, &log.Detail, &log.User, &log.IP, &log.UserAgent, &successSet, &success, &log.Error); err != nil {
return nil, err
}
if successSet != 0 {
value := success != 0
log.Success = &value
}
result = append(result, log)
}
return result, rows.Err()
}
func loadTasks() ([]SavedTask, error) {
rows, err := db.Query(`SELECT
id, type, container_id, container_name, status, error, created_at, template_id, user, ip, user_agent,
cfg_name, cfg_virtualization, cfg_template_id, cfg_vcpu, cfg_cpu_percent, cfg_ram_mb, cfg_disk_gb,
cfg_network_bw_mbps, cfg_monthly_traffic_gb, cfg_traffic_mode, cfg_traffic_in_gb,
cfg_traffic_out_gb, cfg_io_speed_mbps, cfg_port_mapping_count, cfg_snapshot_limit,
cfg_assign_ipv6, cfg_expires_at
FROM tasks ORDER BY created_at, id`)
if err != nil {
return nil, err
}
defer rows.Close()
result := []SavedTask{}
configs := []savedTaskConfig{}
for rows.Next() {
var t SavedTask
var cfg savedTaskConfig
var assignIPv6 int
var ip, userAgent sql.NullString
if err := rows.Scan(
&t.ID, &t.Type, &t.ContainerID, &t.ContainerName, &t.Status, &t.Error, &t.CreatedAt, &t.TemplateID, &t.User, &ip, &userAgent,
&cfg.Name, &cfg.Virtualization, &cfg.TemplateID, &cfg.VCPU, &cfg.CPUPercent, &cfg.RAMMB, &cfg.DiskGB,
&cfg.NetworkBWMbps, &cfg.MonthlyTrafficGB, &cfg.TrafficMode, &cfg.TrafficInGB,
&cfg.TrafficOutGB, &cfg.IOSpeedMBps, &cfg.PortMappingCount, &cfg.SnapshotLimit,
&assignIPv6, &cfg.ExpiresAt,
); err != nil {
return nil, err
}
t.IP = ip.String
t.UserAgent = userAgent.String
cfg.AssignIPv6 = assignIPv6 != 0
result = append(result, t)
configs = append(configs, cfg)
}
if err := rows.Err(); err != nil {
return nil, err
}
if err := rows.Close(); err != nil {
return nil, err
}
for i := range result {
configs[i].ExtraPorts, err = loadTaskExtraPorts(result[i].ID)
if err != nil {
return nil, err
}
result[i].Config = encodeSavedTaskConfig(configs[i])
}
return result, nil
}
func loadTaskExtraPorts(taskID string) ([]int, error) {
rows, err := db.Query(`SELECT port FROM task_extra_ports WHERE task_id = ? ORDER BY position`, taskID)
if err != nil {
return nil, err
}
defer rows.Close()
result := []int{}
for rows.Next() {
var port int
if err := rows.Scan(&port); err != nil {
return nil, err
}
result = append(result, port)
}
return result, rows.Err()
}
func loadLoginLogs() ([]SavedLoginLog, error) {
rows, err := db.Query(`SELECT time, username, ip, user_agent, success FROM login_logs ORDER BY id`)
if err != nil {
return nil, err
}
defer rows.Close()
result := []SavedLoginLog{}
for rows.Next() {
var log SavedLoginLog
var success int
if err := rows.Scan(&log.Time, &log.Username, &log.IP, &log.UserAgent, &success); err != nil {
return nil, err
}
log.Success = success != 0
result = append(result, log)
}
return result, rows.Err()
}
func loadEnabledImages() ([]string, error) {
rows, err := db.Query(`SELECT image_id FROM enabled_images ORDER BY position`)
if err != nil {
return nil, err
}
defer rows.Close()
result := []string{}
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, err
}
result = append(result, id)
}
return result, rows.Err()
}
func loadSnapshots() ([]Snapshot, error) {
rows, err := db.Query(`SELECT id, container_id, container_name, lxc_name, created_at, created_by, scheduled, path, size_bytes FROM snapshots ORDER BY created_at, id`)
if err != nil {
return nil, err
}
defer rows.Close()
result := []Snapshot{}
for rows.Next() {
var snapshot Snapshot
var scheduled int
if err := rows.Scan(&snapshot.ID, &snapshot.ContainerID, &snapshot.ContainerName, &snapshot.LXCName, &snapshot.CreatedAt, &snapshot.CreatedBy, &scheduled, &snapshot.Path, &snapshot.SizeBytes); err != nil {
return nil, err
}
snapshot.Scheduled = scheduled != 0
result = append(result, snapshot)
}
return result, rows.Err()
}
func loadLegacyJSONConfig(path string) (*ClicdConfig, bool, error) {
data, err := os.ReadFile(path)
if os.IsNotExist(err) {
return nil, false, nil
}
if err != nil {
return nil, false, fmt.Errorf("failed to read legacy config: %v", err)
}
cfg := &ClicdConfig{}
if err := json.Unmarshal(data, cfg); err != nil {
return nil, false, fmt.Errorf("failed to parse legacy config: %v", err)
}
return cfg, true, nil
}
func boolInt(value bool) int {
if value {
return 1
}
return 0
}
func btoa(value bool) string {
if value {
return "1"
}
return "0"
}
func atob(value string) bool {
return value == "1" || strings.EqualFold(value, "true")
}
func atoi(value string) int {
n, _ := strconv.Atoi(value)
return n
}
@@ -0,0 +1,126 @@
package config
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
func TestSQLiteConfigMigratesLegacyJSONAndPersists(t *testing.T) {
resetConfigStoreForTest(t)
dir := t.TempDir()
t.Cleanup(func() {
resetConfigStoreForTest(t)
})
legacyPath := filepath.Join(dir, "config.json")
SetConfigPath(legacyPath)
legacy := ClicdConfig{
AdminUser: "admin",
AdminPassHash: "hash",
JWTSecret: "secret",
Port: 8999,
DataDir: dir,
NextContainerID: 2,
NextVNCPort: 5900,
NextSSHPort: 22000,
Containers: []Container{{
ID: 1,
UUID: "uuid-1",
Name: "ct1",
Virtualization: "lxc",
Template: "debian-12",
Status: "running",
PortMappingLimit: 2,
SnapshotLimit: 3,
PortMappings: []PortMapping{{
ContainerPort: 22,
HostPort: 22001,
Protocol: "tcp",
Description: "SSH",
}},
}},
AuditLogs: []AuditLog{{
Time: "2026-06-07 17:29:00",
Action: "security_horizontal_scan",
Target: "ct1",
Detail: "[medium] 可疑横向探测",
User: "system",
}},
LoginLogs: []SavedLoginLog{{
Time: "2026-06-07 17:29:01 CST",
Username: "admin",
IP: "127.0.0.1",
UserAgent: "test",
Success: true,
}},
Tasks: []SavedTask{{
ID: "task-1",
Type: "create",
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}`,
}},
EnabledImages: []string{"debian-12"},
Snapshots: []Snapshot{{
ID: "snap-1",
ContainerID: 1,
ContainerName: "ct1",
LXCName: "ct-1",
CreatedAt: "2026-06-07 17:30:00",
Path: filepath.Join(dir, "snap-1"),
}},
}
data, err := json.Marshal(legacy)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(legacyPath, data, 0600); err != nil {
t.Fatal(err)
}
cfg, err := InitConfig()
if err != nil {
t.Fatal(err)
}
if len(cfg.Containers) != 1 || len(cfg.Containers[0].PortMappings) != 1 {
t.Fatalf("legacy config was not migrated: %+v", cfg.Containers)
}
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 _, err := os.Stat(filepath.Join(dir, "config.db")); err != nil {
t.Fatalf("sqlite database was not created: %v", err)
}
cfg.Containers[0].Status = "stopped"
if err := SaveConfig(); err != nil {
t.Fatal(err)
}
resetConfigStoreForTest(t)
SetConfigPath(legacyPath)
cfg, err = InitConfig()
if err != nil {
t.Fatal(err)
}
if got := cfg.Containers[0].Status; got != "stopped" {
t.Fatalf("expected sqlite value to win after migration, got %q", got)
}
}
func resetConfigStoreForTest(t *testing.T) {
t.Helper()
if db != nil {
if err := db.Close(); err != nil {
t.Fatal(err)
}
db = nil
}
AppConfig = nil
configPath = ""
}
+237 -28
View File
@@ -2,7 +2,9 @@ package kvm
import ( import (
"bytes" "bytes"
"context"
"crypto/rand" "crypto/rand"
"crypto/sha256"
"encoding/base64" "encoding/base64"
"encoding/binary" "encoding/binary"
"encoding/hex" "encoding/hex"
@@ -35,6 +37,7 @@ type Manager struct {
} }
const ipv6GatewayLinkLocal = "fe80::1" const ipv6GatewayLinkLocal = "fe80::1"
const libvirtDefaultNetworkMarker = "/var/lib/clicd/kvm/default-network.created"
type usageSample struct { type usageSample struct {
CPUUsec uint64 CPUUsec uint64
@@ -114,7 +117,22 @@ func ImageDownloadedInfo(id string) (bool, int64) {
return true, info.Size() return true, info.Size()
} }
// DownloadProgress reports KVM image download/conversion progress.
type DownloadProgress struct {
Stage string
DownloadedBytes int64
TotalBytes int64
Percent int
}
// DownloadProgressFunc receives download progress updates.
type DownloadProgressFunc func(DownloadProgress)
func DownloadImage(image Image) error { func DownloadImage(image Image) error {
return DownloadImageWithProgress(context.Background(), image, nil)
}
func DownloadImageWithProgress(ctx context.Context, image Image, progress DownloadProgressFunc) error {
if err := os.MkdirAll(CacheDir(), 0755); err != nil { if err := os.MkdirAll(CacheDir(), 0755); err != nil {
return err return err
} }
@@ -134,11 +152,15 @@ func DownloadImage(image Image) error {
tmp := target + ".tmp" tmp := target + ".tmp"
_ = os.Remove(tmp) _ = os.Remove(tmp)
if image.Distro == "windows" { if image.Distro == "windows" {
if err := downloadFileWithValidator(image.URL, tmp, validateWindowsISOResponse(target)); err != nil { if err := downloadFileWithValidator(ctx, image.URL, tmp, validateWindowsISOResponse(target), progress); err != nil {
_ = os.Remove(tmp) _ = os.Remove(tmp)
return err return err
} }
} else if err := downloadFile(image.URL, tmp); err != nil { } else if err := downloadFile(ctx, image.URL, tmp, progress); err != nil {
_ = os.Remove(tmp)
return err
}
if err := ctx.Err(); err != nil {
_ = os.Remove(tmp) _ = os.Remove(tmp)
return err return err
} }
@@ -153,8 +175,12 @@ func DownloadImage(image Image) error {
return err return err
} }
} else { } else {
if err := normalizeQCOW2(tmp, target); err != nil { if progress != nil {
progress(DownloadProgress{Stage: "converting", Percent: 100})
}
if err := normalizeQCOW2(ctx, tmp, target); err != nil {
_ = os.Remove(tmp) _ = os.Remove(tmp)
_ = os.Remove(target)
return err return err
} }
} }
@@ -168,11 +194,11 @@ func DeleteImage(id string) error {
type downloadResponseValidator func(*http.Response) error type downloadResponseValidator func(*http.Response) error
func downloadFile(url, target string) error { func downloadFile(ctx context.Context, url, target string, progress DownloadProgressFunc) error {
return downloadFileWithValidator(url, target, nil) return downloadFileWithValidator(ctx, url, target, nil, progress)
} }
func downloadFileWithValidator(url, target string, validate downloadResponseValidator) error { func downloadFileWithValidator(ctx context.Context, url, target string, validate downloadResponseValidator, progress DownloadProgressFunc) error {
client := http.Client{ client := http.Client{
Timeout: 30 * time.Minute, Timeout: 30 * time.Minute,
CheckRedirect: func(req *http.Request, via []*http.Request) error { CheckRedirect: func(req *http.Request, via []*http.Request) error {
@@ -186,7 +212,7 @@ func downloadFileWithValidator(url, target string, validate downloadResponseVali
return nil return nil
}, },
} }
req, err := http.NewRequest("GET", url, nil) req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil { if err != nil {
return err return err
} }
@@ -210,7 +236,48 @@ func downloadFileWithValidator(url, target string, validate downloadResponseVali
return err return err
} }
defer out.Close() defer out.Close()
if _, err := io.Copy(out, resp.Body); err != nil { total := resp.ContentLength
if total < 0 {
total = 0
}
if progress != nil {
progress(DownloadProgress{Stage: "downloading", TotalBytes: total})
}
buf := make([]byte, 256*1024)
var downloaded int64
for {
if err := ctx.Err(); err != nil {
return err
}
n, readErr := resp.Body.Read(buf)
if n > 0 {
written, writeErr := out.Write(buf[:n])
downloaded += int64(written)
if writeErr != nil {
return writeErr
}
if written != n {
return io.ErrShortWrite
}
if progress != nil {
percent := 0
if total > 0 {
percent = int(downloaded * 100 / total)
if percent > 99 {
percent = 99
}
}
progress(DownloadProgress{Stage: "downloading", DownloadedBytes: downloaded, TotalBytes: total, Percent: percent})
}
}
if readErr == io.EOF {
break
}
if readErr != nil {
return readErr
}
}
if err := ctx.Err(); err != nil {
return err return err
} }
return out.Sync() return out.Sync()
@@ -266,11 +333,11 @@ func validateWindowsISO(path, target string) error {
return nil return nil
} }
func normalizeQCOW2(src, target string) error { func normalizeQCOW2(ctx context.Context, src, target string) error {
if err := requireCommand("qemu-img"); err != nil { if err := requireCommand("qemu-img"); err != nil {
return err return err
} }
cmd := exec.Command("qemu-img", "convert", "-O", "qcow2", src, target) cmd := exec.CommandContext(ctx, "qemu-img", "convert", "-O", "qcow2", src, target)
if output, err := cmd.CombinedOutput(); err != nil { if output, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("qemu-img convert failed: %v, output: %s", err, string(output)) return fmt.Errorf("qemu-img convert failed: %v, output: %s", err, string(output))
} }
@@ -371,13 +438,21 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig
} }
xml = windowsDomainXML(vmName, int(cfg.VCPU), cfg.RAMMB, diskPath, ImagePath(image.ID), unattendPath, mac, cfg.IOSpeedMBps, cfg.NetworkBWMbps) xml = windowsDomainXML(vmName, int(cfg.VCPU), cfg.RAMMB, diskPath, ImagePath(image.ID), unattendPath, mac, cfg.IOSpeedMBps, cfg.NetworkBWMbps)
} else { } else {
if image.Desktop != "" {
if cfg.RAMMB < 2048 {
cfg.RAMMB = 2048
}
if cfg.DiskGB < 20 {
cfg.DiskGB = 20
}
}
if err := createOverlayDisk(ImagePath(image.ID), diskPath, cfg.DiskGB); err != nil { if err := createOverlayDisk(ImagePath(image.ID), diskPath, cfg.DiskGB); err != nil {
return nil, err return nil, err
} }
if err := createSeedISO(seedPath, vmName, cfg.Name, sshPassword, mac, ipv6); err != nil { if err := createSeedISO(seedPath, vmName, cfg.Name, sshPassword, mac, ipv6, *image); err != nil {
return nil, err return nil, err
} }
xml = domainXML(vmName, int(cfg.VCPU), cfg.RAMMB, diskPath, seedPath, mac, cfg.IOSpeedMBps, cfg.NetworkBWMbps) xml = domainXML(vmName, int(cfg.VCPU), cfg.RAMMB, diskPath, seedPath, mac, cfg.IOSpeedMBps, cfg.NetworkBWMbps, image.Desktop != "")
} }
xmlPath := filepath.Join(m.instanceDir(vmName), "domain.xml") xmlPath := filepath.Join(m.instanceDir(vmName), "domain.xml")
if err := os.WriteFile(xmlPath, []byte(xml), 0644); err != nil { if err := os.WriteFile(xmlPath, []byte(xml), 0644); err != nil {
@@ -639,7 +714,7 @@ func (m *Manager) ReinstallContainer(id int, templateID string) error {
return m.StartContainer(id) return m.StartContainer(id)
} }
func (m *Manager) ResetSSHPassword(id int) (string, error) { func (m *Manager) ResetSSHPassword(id int, password string) (string, error) {
c := config.FindContainer(id) c := config.FindContainer(id)
if c == nil { if c == nil {
return "", fmt.Errorf("container not found: %d", id) return "", fmt.Errorf("container not found: %d", id)
@@ -650,7 +725,9 @@ func (m *Manager) ResetSSHPassword(id int) (string, error) {
if c.Status != "running" { if c.Status != "running" {
return "", fmt.Errorf("KVM VM must be running before password reset") return "", fmt.Errorf("KVM VM must be running before password reset")
} }
password := generateRandomString(16) if strings.TrimSpace(password) == "" {
password = generateRandomString(16)
}
if err := runKVMGuestAgentSSHSetup(c.VirshName(), password); err == nil { if err := runKVMGuestAgentSSHSetup(c.VirshName(), password); err == nil {
c.SSHPassword = password c.SSHPassword = password
c.SSHHostKey = "" c.SSHHostKey = ""
@@ -663,10 +740,14 @@ func (m *Manager) ResetSSHPassword(id int) (string, error) {
if err := m.EnsureSSH(id); err != nil { if err := m.EnsureSSH(id); err != nil {
return "", err return "", err
} }
chpasswdInput, err := chpasswdStdin("root", password)
if err != nil {
return "", err
}
client, err := ssh.Dial("tcp", net.JoinHostPort(c.IP, "22"), &ssh.ClientConfig{ client, err := ssh.Dial("tcp", net.JoinHostPort(c.IP, "22"), &ssh.ClientConfig{
User: "root", User: "root",
Auth: []ssh.AuthMethod{ssh.Password(c.SSHPassword)}, Auth: []ssh.AuthMethod{ssh.Password(c.SSHPassword)},
HostKeyCallback: ssh.InsecureIgnoreHostKey(), HostKeyCallback: kvmHostKeyCallback(c),
Timeout: 8 * time.Second, Timeout: 8 * time.Second,
}) })
if err != nil { if err != nil {
@@ -678,8 +759,8 @@ func (m *Manager) ResetSSHPassword(id int) (string, error) {
return "", err return "", err
} }
defer session.Close() defer session.Close()
cmd := fmt.Sprintf("printf 'root:%s\\n' | chpasswd", shellQuote(password)) session.Stdin = bytes.NewReader(chpasswdInput)
if output, err := session.CombinedOutput(cmd); err != nil { if output, err := session.CombinedOutput("chpasswd"); err != nil {
return "", fmt.Errorf("failed to reset password: %v, output: %s", err, string(output)) return "", fmt.Errorf("failed to reset password: %v, output: %s", err, string(output))
} }
c.SSHPassword = password c.SSHPassword = password
@@ -706,7 +787,7 @@ func (m *Manager) ApplyContainerLimits(c *config.Container) error {
xml = windowsDomainXML(c.VirshName(), int(c.VCPU), c.RAMMB, c.DiskImage, winISO, unattendISO, c.MACAddress, c.IOSpeedMBps, c.NetworkBWMbps) xml = windowsDomainXML(c.VirshName(), int(c.VCPU), c.RAMMB, c.DiskImage, winISO, unattendISO, c.MACAddress, c.IOSpeedMBps, c.NetworkBWMbps)
} else { } else {
seedPath := filepath.Join(m.instanceDir(c.VirshName()), "seed.iso") seedPath := filepath.Join(m.instanceDir(c.VirshName()), "seed.iso")
xml = domainXML(c.VirshName(), int(c.VCPU), c.RAMMB, c.DiskImage, seedPath, c.MACAddress, c.IOSpeedMBps, c.NetworkBWMbps) xml = domainXML(c.VirshName(), int(c.VCPU), c.RAMMB, c.DiskImage, seedPath, c.MACAddress, c.IOSpeedMBps, c.NetworkBWMbps, isKVMDesktopTemplate(c.Template))
} }
xmlPath := filepath.Join(m.instanceDir(c.VirshName()), "domain.xml") xmlPath := filepath.Join(m.instanceDir(c.VirshName()), "domain.xml")
if err := os.WriteFile(xmlPath, []byte(xml), 0644); err != nil { if err := os.WriteFile(xmlPath, []byte(xml), 0644); err != nil {
@@ -731,7 +812,7 @@ func (m *Manager) ensureDomainDefinition(c *config.Container) error {
xml = windowsDomainXML(c.VirshName(), int(c.VCPU), c.RAMMB, c.DiskImage, winISO, unattendISO, c.MACAddress, c.IOSpeedMBps, c.NetworkBWMbps) xml = windowsDomainXML(c.VirshName(), int(c.VCPU), c.RAMMB, c.DiskImage, winISO, unattendISO, c.MACAddress, c.IOSpeedMBps, c.NetworkBWMbps)
} else { } else {
seedPath := filepath.Join(m.instanceDir(c.VirshName()), "seed.iso") seedPath := filepath.Join(m.instanceDir(c.VirshName()), "seed.iso")
xml = domainXML(c.VirshName(), int(c.VCPU), c.RAMMB, c.DiskImage, seedPath, c.MACAddress, c.IOSpeedMBps, c.NetworkBWMbps) xml = domainXML(c.VirshName(), int(c.VCPU), c.RAMMB, c.DiskImage, seedPath, c.MACAddress, c.IOSpeedMBps, c.NetworkBWMbps, isKVMDesktopTemplate(c.Template))
} }
if err := os.WriteFile(xmlPath, []byte(xml), 0644); err != nil { if err := os.WriteFile(xmlPath, []byte(xml), 0644); err != nil {
return err return err
@@ -1396,10 +1477,13 @@ func ensureDefaultNetwork() error {
if out, err := exec.Command("virsh", "net-define", tmpFile).CombinedOutput(); err != nil { if out, err := exec.Command("virsh", "net-define", tmpFile).CombinedOutput(); err != nil {
return fmt.Errorf("failed to define libvirt default network: %v, output: %s", err, string(out)) return fmt.Errorf("failed to define libvirt default network: %v, output: %s", err, string(out))
} }
if err := os.MkdirAll(filepath.Dir(libvirtDefaultNetworkMarker), 0755); err == nil {
_ = os.WriteFile(libvirtDefaultNetworkMarker, []byte("created-by-clicd\n"), 0644)
}
} }
// Start and autostart the default network // Start and autostart the default network
if out, err := exec.Command("virsh", "net-info", "default").Output(); err == nil { if out, err := exec.Command("virsh", "net-info", "default").Output(); err == nil {
if !strings.Contains(strings.ToLower(string(out)), "active:") || !strings.Contains(strings.ToLower(string(out)), "yes") { if !libvirtNetworkActive(string(out)) {
if startOut, startErr := exec.Command("virsh", "net-start", "default").CombinedOutput(); startErr != nil { if startOut, startErr := exec.Command("virsh", "net-start", "default").CombinedOutput(); startErr != nil {
return fmt.Errorf("failed to start libvirt default network: %v, output: %s", startErr, string(startOut)) return fmt.Errorf("failed to start libvirt default network: %v, output: %s", startErr, string(startOut))
} }
@@ -1411,6 +1495,19 @@ func ensureDefaultNetwork() error {
return nil return nil
} }
func libvirtNetworkActive(info string) bool {
for _, line := range strings.Split(info, "\n") {
key, value, ok := strings.Cut(line, ":")
if !ok {
continue
}
if strings.EqualFold(strings.TrimSpace(key), "Active") {
return strings.EqualFold(strings.TrimSpace(value), "yes")
}
}
return false
}
func createOverlayDisk(base, target string, diskGB int) error { func createOverlayDisk(base, target string, diskGB int) error {
if diskGB < 1 { if diskGB < 1 {
diskGB = 5 diskGB = 5
@@ -1438,7 +1535,7 @@ func ensureVirtioWinISO() error {
virtioURL := "https://fedorapeople.org/groups/virt/virtio-win/direct-downloads/stable-virtio/virtio-win.iso" virtioURL := "https://fedorapeople.org/groups/virt/virtio-win/direct-downloads/stable-virtio/virtio-win.iso"
tmp := virtioPath + ".tmp" tmp := virtioPath + ".tmp"
_ = os.Remove(tmp) _ = os.Remove(tmp)
if err := downloadFile(virtioURL, tmp); err != nil { if err := downloadFile(context.Background(), virtioURL, tmp, nil); err != nil {
_ = os.Remove(tmp) _ = os.Remove(tmp)
return fmt.Errorf("failed to download virtio-win.iso: %v", err) return fmt.Errorf("failed to download virtio-win.iso: %v", err)
} }
@@ -1668,8 +1765,11 @@ func shellQuoteWindows(value string) string {
return `"` + strings.ReplaceAll(value, `"`, `\"`) + `"` return `"` + strings.ReplaceAll(value, `"`, `\"`) + `"`
} }
func createSeedISO(seedPath, instanceID, hostname, password, mac, ipv6 string) error { func createSeedISO(seedPath, instanceID, hostname, password, mac, ipv6 string, image Image) error {
guestSetup := kvmSSHSetupScript(password) guestSetup := kvmSSHSetupScript(password)
if desktopSetup := kvmDesktopSetupScript(image); desktopSetup != "" {
guestSetup += "\n" + desktopSetup
}
if strings.TrimSpace(ipv6) != "" { if strings.TrimSpace(ipv6) != "" {
guestSetup += "\n" + kvmIPv6SetupScript(ipv6) guestSetup += "\n" + kvmIPv6SetupScript(ipv6)
} }
@@ -1743,7 +1843,12 @@ func indentScript(script string, spaces int) string {
return strings.Join(lines, "\n") return strings.Join(lines, "\n")
} }
func domainXML(name string, vcpu int, ramMB int, diskPath, seedPath, mac string, ioSpeedMBps int, networkBWMbps int) string { func isKVMDesktopTemplate(templateID string) bool {
image := FindImage(templateID)
return image != nil && image.Desktop != ""
}
func domainXML(name string, vcpu int, ramMB int, diskPath, seedPath, mac string, ioSpeedMBps int, networkBWMbps int, desktop bool) string {
if vcpu < 1 { if vcpu < 1 {
vcpu = 1 vcpu = 1
} }
@@ -1767,6 +1872,12 @@ func domainXML(name string, vcpu int, ramMB int, diskPath, seedPath, mac string,
<outbound average='%d'/> <outbound average='%d'/>
</bandwidth>`, averageKiB, averageKiB) </bandwidth>`, averageKiB, averageKiB)
} }
video := "<video><model type='virtio'/></video>"
input := ""
if desktop {
video = "<video><model type='qxl' ram='65536' vram='65536' heads='1' primary='yes'/></video>"
input = "\n\t <input type='tablet' bus='usb'/>"
}
return fmt.Sprintf(`<domain type='kvm'> return fmt.Sprintf(`<domain type='kvm'>
<name>%s</name> <name>%s</name>
%s %s
@@ -1810,10 +1921,10 @@ func domainXML(name string, vcpu int, ramMB int, diskPath, seedPath, mac string,
<memballoon model='virtio'> <memballoon model='virtio'>
<stats period='10'/> <stats period='10'/>
</memballoon> </memballoon>
<graphics type='vnc' port='-1' autoport='yes' listen='127.0.0.1'/> <graphics type='vnc' port='-1' autoport='yes' listen='127.0.0.1'/>%s
<video><model type='virtio'/></video> %s
</devices> </devices>
</domain>`, xmlEscape(name), domainUUIDXML(name), ramMB, ramMB, vcpu, vcpu, xmlEscape(diskPath), iotune, xmlEscape(seedPath), xmlEscape(mac), bandwidth) </domain>`, xmlEscape(name), domainUUIDXML(name), ramMB, ramMB, vcpu, vcpu, xmlEscape(diskPath), iotune, xmlEscape(seedPath), xmlEscape(mac), bandwidth, input, video)
} }
func windowsDomainXML(name string, vcpu int, ramMB int, diskPath, winISOPath, unattendISOPath, mac string, ioSpeedMBps int, networkBWMbps int) string { func windowsDomainXML(name string, vcpu int, ramMB int, diskPath, winISOPath, unattendISOPath, mac string, ioSpeedMBps int, networkBWMbps int) string {
@@ -2122,7 +2233,7 @@ func (m *Manager) EnsureSSH(id int) error {
client, err := ssh.Dial("tcp", net.JoinHostPort(c.IP, "22"), &ssh.ClientConfig{ client, err := ssh.Dial("tcp", net.JoinHostPort(c.IP, "22"), &ssh.ClientConfig{
User: "root", User: "root",
Auth: []ssh.AuthMethod{ssh.Password(c.SSHPassword)}, Auth: []ssh.AuthMethod{ssh.Password(c.SSHPassword)},
HostKeyCallback: ssh.InsecureIgnoreHostKey(), HostKeyCallback: kvmHostKeyCallback(c),
Timeout: 8 * time.Second, Timeout: 8 * time.Second,
}) })
if err != nil { if err != nil {
@@ -2263,6 +2374,64 @@ fi
` `
} }
func kvmDesktopSetupScript(image Image) string {
if strings.ToLower(strings.TrimSpace(image.Desktop)) != "xfce" {
return ""
}
packages := ""
switch image.Distro {
case "ubuntu":
packages = "xubuntu-desktop"
case "debian":
packages = "task-xfce-desktop"
default:
return ""
}
return `if command -v apt-get >/dev/null 2>&1; then
{
exec >>/var/log/clicd-desktop-setup.log 2>&1
echo "CLICD XFCE setup started at $(date -Is)"
export DEBIAN_FRONTEND=noninteractive
export APT_LISTCHANGES_FRONTEND=none
apt-get update || true
apt-get install -y -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold ` + packages + ` || apt-get install -y -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold xfce4 lightdm lightdm-gtk-greeter dbus-x11 xorg || true
if command -v useradd >/dev/null 2>&1 && ! id clicd >/dev/null 2>&1; then
useradd -m -s /bin/bash clicd || true
fi
if command -v chpasswd >/dev/null 2>&1 && id clicd >/dev/null 2>&1; then
printf 'clicd:%s\n' "$ROOT_PASSWORD" | chpasswd || true
fi
usermod -aG sudo clicd >/dev/null 2>&1 || true
usermod -aG autologin clicd >/dev/null 2>&1 || true
if id clicd >/dev/null 2>&1; then
printf 'startxfce4\n' >/home/clicd/.xsession || true
chown clicd:clicd /home/clicd/.xsession >/dev/null 2>&1 || true
fi
mkdir -p /etc/lightdm/lightdm.conf.d
cat >/etc/lightdm/lightdm.conf.d/50-clicd-autologin.conf <<'EOF'
[Seat:*]
autologin-user=clicd
autologin-user-timeout=0
user-session=xfce
greeter-session=lightdm-gtk-greeter
EOF
if [ -x /usr/sbin/lightdm ]; then
printf '/usr/sbin/lightdm\n' >/etc/X11/default-display-manager || true
fi
if command -v systemctl >/dev/null 2>&1; then
systemctl daemon-reload >/dev/null 2>&1 || true
systemctl set-default graphical.target >/dev/null 2>&1 || true
systemctl enable display-manager.service >/dev/null 2>&1 || true
systemctl enable lightdm.service >/dev/null 2>&1 || true
systemctl restart lightdm.service >/dev/null 2>&1 || systemctl start lightdm.service >/dev/null 2>&1 || true
fi
apt-get clean || true
echo "CLICD XFCE setup finished at $(date -Is)"
} || true
fi
`
}
func qemuGuestPing(name string) error { func qemuGuestPing(name string) error {
out, err := exec.Command("virsh", "qemu-agent-command", name, `{"execute":"guest-ping"}`).CombinedOutput() out, err := exec.Command("virsh", "qemu-agent-command", name, `{"execute":"guest-ping"}`).CombinedOutput()
if err != nil { if err != nil {
@@ -3030,7 +3199,7 @@ func (m *Manager) applyGuestIPv6OverSSH(c *config.Container) error {
client, err := ssh.Dial("tcp", net.JoinHostPort(c.IP, "22"), &ssh.ClientConfig{ client, err := ssh.Dial("tcp", net.JoinHostPort(c.IP, "22"), &ssh.ClientConfig{
User: "root", User: "root",
Auth: []ssh.AuthMethod{ssh.Password(c.SSHPassword)}, Auth: []ssh.AuthMethod{ssh.Password(c.SSHPassword)},
HostKeyCallback: ssh.InsecureIgnoreHostKey(), HostKeyCallback: kvmHostKeyCallback(c),
Timeout: 8 * time.Second, Timeout: 8 * time.Second,
}) })
if err != nil { if err != nil {
@@ -3217,6 +3386,46 @@ func shellQuote(value string) string {
return "'" + strings.ReplaceAll(value, "'", "'\"'\"'") + "'" return "'" + strings.ReplaceAll(value, "'", "'\"'\"'") + "'"
} }
func chpasswdStdin(username, password string) ([]byte, error) {
if username == "" || strings.ContainsAny(username, ":\n\r") {
return nil, fmt.Errorf("invalid chpasswd username")
}
if strings.ContainsAny(password, "\n\r") {
return nil, fmt.Errorf("password cannot contain newlines")
}
return []byte(username + ":" + password + "\n"), nil
}
func kvmHostKeyCallback(c *config.Container) ssh.HostKeyCallback {
return func(hostname string, remote net.Addr, key ssh.PublicKey) error {
return verifyKVMHostKey(c, key, config.SaveConfig)
}
}
func verifyKVMHostKey(c *config.Container, key ssh.PublicKey, save func() error) error {
if c == nil {
return fmt.Errorf("KVM container is nil")
}
fingerprint := sshHostKeyFingerprint(key)
if c.SSHHostKey != "" && c.SSHHostKey != fingerprint {
return fmt.Errorf("KVM SSH host key mismatch")
}
if c.SSHHostKey == "" {
c.SSHHostKey = fingerprint
if save != nil {
if err := save(); err != nil {
return fmt.Errorf("failed to save KVM SSH host key: %v", err)
}
}
}
return nil
}
func sshHostKeyFingerprint(key ssh.PublicKey) string {
sum := sha256.Sum256(key.Marshal())
return hex.EncodeToString(sum[:])
}
func allocateDefaultEqualPorts(c *config.Container, count int) []int { func allocateDefaultEqualPorts(c *config.Container, count int) []int {
if count <= 0 { if count <= 0 {
return nil return nil
+95
View File
@@ -0,0 +1,95 @@
package kvm
import (
"crypto/ed25519"
"crypto/rand"
"reflect"
"testing"
"clicd/internal/config"
"golang.org/x/crypto/ssh"
)
func TestChpasswdStdinPreservesShellMetacharacters(t *testing.T) {
password := `pa'";$(touch /tmp/pwned); echo #\\word`
got, err := chpasswdStdin("root", password)
if err != nil {
t.Fatalf("chpasswdStdin returned error: %v", err)
}
want := []byte("root:" + password + "\n")
if !reflect.DeepEqual(got, want) {
t.Fatalf("chpasswdStdin = %#v, want %#v", got, want)
}
}
func TestChpasswdStdinRejectsNewlines(t *testing.T) {
tests := []struct {
name string
username string
password string
}{
{name: "username newline", username: "root\nadmin", password: "safe"},
{name: "username colon", username: "root:admin", password: "safe"},
{name: "password newline", username: "root", password: "safe\nroot:evil"},
{name: "password carriage return", username: "root", password: "safe\rroot:evil"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if _, err := chpasswdStdin(tc.username, tc.password); err == nil {
t.Fatal("chpasswdStdin returned nil error")
}
})
}
}
func TestVerifyKVMHostKeyCapturesAndRejectsMismatch(t *testing.T) {
key1 := testSSHPublicKey(t)
key2 := testSSHPublicKey(t)
saves := 0
c := &config.Container{}
save := func() error {
saves++
return nil
}
if err := verifyKVMHostKey(c, key1, save); err != nil {
t.Fatalf("first host key verification returned error: %v", err)
}
if c.SSHHostKey == "" {
t.Fatal("first host key verification did not capture fingerprint")
}
if c.SSHHostKey != sshHostKeyFingerprint(key1) {
t.Fatalf("captured fingerprint = %q, want %q", c.SSHHostKey, sshHostKeyFingerprint(key1))
}
if saves != 1 {
t.Fatalf("save count = %d, want 1", saves)
}
if err := verifyKVMHostKey(c, key1, save); err != nil {
t.Fatalf("same host key verification returned error: %v", err)
}
if saves != 1 {
t.Fatalf("save count after same key = %d, want 1", saves)
}
if err := verifyKVMHostKey(c, key2, save); err == nil {
t.Fatal("mismatched host key verification returned nil error")
}
}
func testSSHPublicKey(t *testing.T) ssh.PublicKey {
t.Helper()
_, privateKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
signer, err := ssh.NewSignerFromKey(privateKey)
if err != nil {
t.Fatal(err)
}
return signer.PublicKey()
}
+16 -1
View File
@@ -12,6 +12,7 @@ type Image struct {
Arch string `json:"arch"` Arch string `json:"arch"`
Description string `json:"description"` Description string `json:"description"`
URL string `json:"url"` URL string `json:"url"`
Desktop string `json:"desktop,omitempty"`
} }
func GetImages() []Image { func GetImages() []Image {
@@ -22,6 +23,13 @@ func GetImages() []Image {
Description: "Ubuntu 24.04 LTS cloud image for KVM", Description: "Ubuntu 24.04 LTS cloud image for KVM",
URL: "https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img", URL: "https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img",
}, },
{
ID: "kvm-ubuntu-noble-xfce", Name: "Ubuntu 24.04 XFCE KVM",
Distro: "ubuntu", Release: "noble", Arch: "amd64",
Description: "Ubuntu 24.04 LTS cloud image with XFCE desktop provisioned via cloud-init",
URL: "https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img",
Desktop: "xfce",
},
{ {
ID: "kvm-ubuntu-jammy", Name: "Ubuntu 22.04 KVM", ID: "kvm-ubuntu-jammy", Name: "Ubuntu 22.04 KVM",
Distro: "ubuntu", Release: "jammy", Arch: "amd64", Distro: "ubuntu", Release: "jammy", Arch: "amd64",
@@ -34,6 +42,13 @@ func GetImages() []Image {
Description: "Debian 12 generic cloud image for KVM", Description: "Debian 12 generic cloud image for KVM",
URL: "https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-amd64.qcow2", URL: "https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-amd64.qcow2",
}, },
{
ID: "kvm-debian-bookworm-xfce", Name: "Debian 12 XFCE KVM",
Distro: "debian", Release: "bookworm", Arch: "amd64",
Description: "Debian 12 generic cloud image with XFCE desktop provisioned via cloud-init",
URL: "https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-amd64.qcow2",
Desktop: "xfce",
},
{ {
ID: "kvm-debian-bullseye", Name: "Debian 11 KVM", ID: "kvm-debian-bullseye", Name: "Debian 11 KVM",
Distro: "debian", Release: "bullseye", Arch: "amd64", Distro: "debian", Release: "bullseye", Arch: "amd64",
@@ -73,7 +88,7 @@ func GetImages() []Image {
{ {
ID: "kvm-windows-10", Name: "Windows 10 KVM", ID: "kvm-windows-10", Name: "Windows 10 KVM",
Distro: "windows", Release: "10", Arch: "amd64", Distro: "windows", Release: "10", Arch: "amd64",
Description: "Windows ISO for KVM (automatic unattended install from image index 1, network, Administrator password, RDP, firewall, and QEMU Guest Agent initialization)", Description: "Windows 10 Enterprise LTSC Evaluation",
URL: "https://go.microsoft.com/fwlink/?LinkID=2195404", URL: "https://go.microsoft.com/fwlink/?LinkID=2195404",
}, },
} }
+320 -68
View File
@@ -11,11 +11,11 @@ import (
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"reflect"
"regexp" "regexp"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
"syscall"
"time" "time"
"clicd/internal/config" "clicd/internal/config"
@@ -390,7 +390,7 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
fmt.Printf("Warning: failed to install IPv6 init in %s: %v\n", lxcName, err) fmt.Printf("Warning: failed to install IPv6 init in %s: %v\n", lxcName, err)
} }
} }
if err := m.preconfigureSSH(rootfsPath, sshPassword, cfg.TemplateID); err != nil { if err := m.preconfigureSSH(rootfsPath, cfg.TemplateID); err != nil {
fmt.Printf("Warning: failed to pre-configure SSH in %s: %v\n", lxcName, err) fmt.Printf("Warning: failed to pre-configure SSH in %s: %v\n", lxcName, err)
} }
@@ -402,9 +402,9 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
// Set root password AFTER shiftRootfsForUnprivileged, // Set root password AFTER shiftRootfsForUnprivileged,
// otherwise /etc/shadow ownership breaks and SSHD cannot authenticate. // otherwise /etc/shadow ownership breaks and SSHD cannot authenticate.
setCmd := m.rootfsCommand(rootfsPath, if err := m.setRootfsPassword(rootfsPath, sshPassword); err != nil {
"sh", "-c", fmt.Sprintf("printf '%%s:%%s\\n' root %s | chpasswd", shellQuote(sshPassword))) fmt.Printf("Warning: failed to set root password in %s: %v\n", lxcName, err)
setCmd.Run() }
fmt.Printf("Container %d (%s) created successfully\n", id, cfg.Name) fmt.Printf("Container %d (%s) created successfully\n", id, cfg.Name)
return nil return nil
@@ -429,7 +429,7 @@ func (m *Manager) preconfigureNetwork(rootfsPath, templateID string) {
content := "auto lo\niface lo inet loopback\n\nauto eth0\niface eth0 inet dhcp\n" content := "auto lo\niface lo inet loopback\n\nauto eth0\niface eth0 inet dhcp\n"
_ = os.MkdirAll(filepath.Dir(interfaces), 0755) _ = os.MkdirAll(filepath.Dir(interfaces), 0755)
_ = os.WriteFile(interfaces, []byte(content), 0644) _ = os.WriteFile(interfaces, []byte(content), 0644)
_ = exec.Command("chroot", rootfsPath, "rc-update", "add", "networking", "boot").Run() _ = m.runRootfsCommand(rootfsPath, "rc-update", "add", "networking", "boot")
return return
} }
@@ -451,7 +451,7 @@ method=ignore
path := filepath.Join(nmDir, "eth0.nmconnection") path := filepath.Join(nmDir, "eth0.nmconnection")
_ = os.WriteFile(path, []byte(keyfile), 0600) _ = os.WriteFile(path, []byte(keyfile), 0600)
} }
_ = exec.Command("chroot", rootfsPath, "systemctl", "enable", "NetworkManager").Run() _ = m.runRootfsCommand(rootfsPath, "systemctl", "enable", "NetworkManager")
} }
networkdDir := filepath.Join(rootfsPath, "etc", "systemd", "network") networkdDir := filepath.Join(rootfsPath, "etc", "systemd", "network")
@@ -466,16 +466,19 @@ IPv6AcceptRA=no
_ = os.WriteFile(filepath.Join(networkdDir, "10-eth0.network"), []byte(network), 0644) _ = os.WriteFile(filepath.Join(networkdDir, "10-eth0.network"), []byte(network), 0644)
} }
if !isRHELFamily { if !isRHELFamily {
_ = exec.Command("chroot", rootfsPath, "systemctl", "enable", "systemd-networkd").Run() _ = m.runRootfsCommand(rootfsPath, "systemctl", "enable", "systemd-networkd")
} }
} }
// preconfigureSSH installs and configures SSH directly in the rootfs before first boot. // preconfigureSSH installs and configures SSH directly in the rootfs before first boot.
func (m *Manager) preconfigureSSH(rootfsPath, password, templateID string) error { func (m *Manager) preconfigureSSH(rootfsPath, templateID string) error {
_ = templateID _ = templateID
ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second)
defer cancel() defer cancel()
cmd := m.rootfsCommand(rootfsPath, "sh", "-c", sshSetupScript(password, false)) cmd, err := m.rootfsCommand(rootfsPath, "sh", "-c", sshSetupScript(false))
if err != nil {
return err
}
cmd = exec.CommandContext(ctx, cmd.Path, cmd.Args[1:]...) cmd = exec.CommandContext(ctx, cmd.Path, cmd.Args[1:]...)
output, err := cmd.CombinedOutput() output, err := cmd.CombinedOutput()
if ctx.Err() == context.DeadlineExceeded { if ctx.Err() == context.DeadlineExceeded {
@@ -989,6 +992,27 @@ func parseSubIDRange(path, user string) (int, error) {
return 0, fmt.Errorf("%s must contain a %s subordinate id range with at least 65536 ids", path, user) return 0, fmt.Errorf("%s must contain a %s subordinate id range with at least 65536 ids", path, user)
} }
func (m *Manager) ensureUnprivilegedLXCPathAccess(lxcName string) error {
// Unprivileged container root maps to a subordinate host UID, so it needs
// execute permission on the LXC parent and container directories to reach
// rootfs. Some distributions create /var/lib/lxc as 750/700, which causes
// lxc-start to abort with "Could not access /var/lib/lxc".
for _, path := range []string{m.LxcPath, filepath.Join(m.LxcPath, lxcName)} {
info, err := os.Stat(path)
if err != nil {
return err
}
mode := info.Mode().Perm()
if mode&0001 != 0 {
continue
}
if err := os.Chmod(path, mode|0001); err != nil {
return fmt.Errorf("failed to fix LXC path permissions for %s: %v", path, err)
}
}
return nil
}
func (m *Manager) shiftRootfsForUnprivileged(lxcName string) error { func (m *Manager) shiftRootfsForUnprivileged(lxcName string) error {
uidBase, gidBase, err := unprivilegedIDMap() uidBase, gidBase, err := unprivilegedIDMap()
if err != nil { if err != nil {
@@ -996,6 +1020,9 @@ func (m *Manager) shiftRootfsForUnprivileged(lxcName string) error {
} }
rootfsPath := filepath.Join(m.LxcPath, lxcName, "rootfs") rootfsPath := filepath.Join(m.LxcPath, lxcName, "rootfs")
marker := filepath.Join(rootfsPath, ".clicd-unprivileged-shifted") marker := filepath.Join(rootfsPath, ".clicd-unprivileged-shifted")
if err := m.ensureUnprivilegedLXCPathAccess(lxcName); err != nil {
return err
}
if _, err := os.Stat(marker); err == nil { if _, err := os.Stat(marker); err == nil {
return nil return nil
} }
@@ -1004,11 +1031,10 @@ func (m *Manager) shiftRootfsForUnprivileged(lxcName string) error {
if err != nil { if err != nil {
return err return err
} }
rootStat, ok := rootInfo.Sys().(*syscall.Stat_t) rootDev, _, _, ok := fileStatFields(rootInfo)
if !ok { if !ok {
return fmt.Errorf("failed to read rootfs device for %s", rootfsPath) return fmt.Errorf("failed to read rootfs device for %s", rootfsPath)
} }
rootDev := rootStat.Dev
if err := filepath.WalkDir(rootfsPath, func(path string, _ os.DirEntry, walkErr error) error { if err := filepath.WalkDir(rootfsPath, func(path string, _ os.DirEntry, walkErr error) error {
if walkErr != nil { if walkErr != nil {
@@ -1018,18 +1044,16 @@ func (m *Manager) shiftRootfsForUnprivileged(lxcName string) error {
if err != nil { if err != nil {
return err return err
} }
stat, ok := info.Sys().(*syscall.Stat_t) dev, uid, gid, ok := fileStatFields(info)
if !ok { if !ok {
return fmt.Errorf("failed to read uid/gid for %s", path) return fmt.Errorf("failed to read uid/gid for %s", path)
} }
if path != rootfsPath && stat.Dev != rootDev { if path != rootfsPath && dev != rootDev {
if info.IsDir() { if info.IsDir() {
return filepath.SkipDir return filepath.SkipDir
} }
return nil return nil
} }
uid := int(stat.Uid)
gid := int(stat.Gid)
if uid >= uidBase && uid < uidBase+65536 && gid >= gidBase && gid < gidBase+65536 { if uid >= uidBase && uid < uidBase+65536 && gid >= gidBase && gid < gidBase+65536 {
return nil return nil
} }
@@ -1039,7 +1063,7 @@ func (m *Manager) shiftRootfsForUnprivileged(lxcName string) error {
if gid >= 0 && gid < 65536 { if gid >= 0 && gid < 65536 {
gid += gidBase gid += gidBase
} }
return syscall.Lchown(path, uid, gid) return os.Lchown(path, uid, gid)
}); err != nil { }); err != nil {
return fmt.Errorf("failed to shift rootfs ownership for unprivileged LXC: %v", err) return fmt.Errorf("failed to shift rootfs ownership for unprivileged LXC: %v", err)
} }
@@ -1047,7 +1071,7 @@ func (m *Manager) shiftRootfsForUnprivileged(lxcName string) error {
if err := os.WriteFile(marker, []byte("1\n"), 0644); err != nil { if err := os.WriteFile(marker, []byte("1\n"), 0644); err != nil {
return err return err
} }
if err := syscall.Lchown(marker, uidBase, gidBase); err != nil { if err := os.Lchown(marker, uidBase, gidBase); err != nil {
return err return err
} }
@@ -1062,6 +1086,48 @@ func (m *Manager) shiftRootfsForUnprivileged(lxcName string) error {
return nil return nil
} }
func fileStatFields(info os.FileInfo) (dev uint64, uid int, gid int, ok bool) {
if info == nil || info.Sys() == nil {
return 0, 0, 0, false
}
stat := reflect.ValueOf(info.Sys())
if stat.Kind() == reflect.Pointer {
if stat.IsNil() {
return 0, 0, 0, false
}
stat = stat.Elem()
}
if stat.Kind() != reflect.Struct {
return 0, 0, 0, false
}
devValue, devOK := numericField(stat, "Dev")
uidValue, uidOK := numericField(stat, "Uid")
gidValue, gidOK := numericField(stat, "Gid")
if !devOK || !uidOK || !gidOK {
return 0, 0, 0, false
}
return devValue, int(uidValue), int(gidValue), true
}
func numericField(v reflect.Value, name string) (uint64, bool) {
field := v.FieldByName(name)
if !field.IsValid() {
return 0, false
}
switch field.Kind() {
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr:
return field.Uint(), true
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
value := field.Int()
if value < 0 {
return 0, false
}
return uint64(value), true
default:
return 0, false
}
}
func (m *Manager) unmountRootfsChildMounts(rootfsPath string) { func (m *Manager) unmountRootfsChildMounts(rootfsPath string) {
rootAbs, err := filepath.Abs(rootfsPath) rootAbs, err := filepath.Abs(rootfsPath)
if err != nil { if err != nil {
@@ -1583,7 +1649,7 @@ func (m *Manager) EnsureSSH(id int) error {
config.SaveConfig() config.SaveConfig()
} }
script := sshSetupScript(c.SSHPassword, true) script := sshSetupScript(true)
ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second)
defer cancel() defer cancel()
@@ -1595,6 +1661,9 @@ func (m *Manager) EnsureSSH(id int) error {
if err != nil { if err != nil {
return fmt.Errorf("failed to configure SSH in container %d: %v, output: %s", id, err, string(output)) return fmt.Errorf("failed to configure SSH in container %d: %v, output: %s", id, err, string(output))
} }
if err := m.quickEnsureSSHPassword(lxcName, c.SSHPassword); err != nil {
return fmt.Errorf("failed to set SSH password in container %d: %v", id, err)
}
if c.IP == "" { if c.IP == "" {
if ip, ipErr := m.GetContainerIP(lxcName); ipErr == nil && ip != "" { if ip, ipErr := m.GetContainerIP(lxcName); ipErr == nil && ip != "" {
@@ -1613,13 +1682,13 @@ func (m *Manager) EnsureSSH(id int) error {
} }
func (m *Manager) quickEnsureSSHPassword(lxcName, password string) error { func (m *Manager) quickEnsureSSHPassword(lxcName, password string) error {
if password == "" { if err := validateRootPassword(password); err != nil {
return fmt.Errorf("empty SSH password") return err
} }
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
defer cancel() defer cancel()
cmd := exec.CommandContext(ctx, "lxc-attach", "-n", lxcName, "--", "sh", "-c", cmd := exec.CommandContext(ctx, "lxc-attach", "-n", lxcName, "--", "chpasswd")
fmt.Sprintf("printf '%%s:%%s\\n' root %s | chpasswd", shellQuote(password))) cmd.Stdin = strings.NewReader(rootPasswordInput(password))
output, err := cmd.CombinedOutput() output, err := cmd.CombinedOutput()
if err != nil { if err != nil {
return fmt.Errorf("failed to update SSH password quickly: %v, output: %s", err, string(output)) return fmt.Errorf("failed to update SSH password quickly: %v, output: %s", err, string(output))
@@ -1627,6 +1696,20 @@ func (m *Manager) quickEnsureSSHPassword(lxcName, password string) error {
return nil return nil
} }
func validateRootPassword(password string) error {
if password == "" {
return fmt.Errorf("empty SSH password")
}
if strings.ContainsAny(password, "\r\n") || strings.ContainsRune(password, '\x00') {
return fmt.Errorf("SSH password contains unsupported control characters")
}
return nil
}
func rootPasswordInput(password string) string {
return "root:" + password + "\n"
}
func (m *Manager) containerPortListening(lxcName string, port int) bool { func (m *Manager) containerPortListening(lxcName string, port int) bool {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() defer cancel()
@@ -1634,9 +1717,8 @@ func (m *Manager) containerPortListening(lxcName string, port int) bool {
return exec.CommandContext(ctx, "lxc-attach", "-n", lxcName, "--", "sh", "-c", check).Run() == nil return exec.CommandContext(ctx, "lxc-attach", "-n", lxcName, "--", "sh", "-c", check).Run() == nil
} }
func sshSetupScript(password string, startService bool) string { func sshSetupScript(startService bool) string {
script := `set -u script := `set -u
ROOT_PASSWORD=` + shellQuote(password) + `
# DNS setup: handle both traditional /etc/resolv.conf and systemd-resolved (Ubuntu 24.04). # DNS setup: handle both traditional /etc/resolv.conf and systemd-resolved (Ubuntu 24.04).
# On modern distros, /etc/resolv.conf is a symlink managed by systemd-resolved. # On modern distros, /etc/resolv.conf is a symlink managed by systemd-resolved.
@@ -1760,11 +1842,6 @@ set_sshd_option KbdInteractiveAuthentication no
set_sshd_option ChallengeResponseAuthentication no set_sshd_option ChallengeResponseAuthentication no
set_sshd_option UsePAM no set_sshd_option UsePAM no
if [ -n "$ROOT_PASSWORD" ]; then
printf '%s:%s\n' root "$ROOT_PASSWORD" | chpasswd || exit 31
passwd -u root >/dev/null 2>&1 || true
fi
if command -v rc-update >/dev/null 2>&1; then if command -v rc-update >/dev/null 2>&1; then
rc-update add sshd default >/dev/null 2>&1 || true rc-update add sshd default >/dev/null 2>&1 || true
fi fi
@@ -1822,14 +1899,17 @@ pgrep -x sshd >/dev/null 2>&1 || exit 33
} }
// ResetSSHPassword resets the root password of a container // ResetSSHPassword resets the root password of a container
func (m *Manager) ResetSSHPassword(id int) (string, error) { func (m *Manager) ResetSSHPassword(id int, password string) (string, error) {
c := config.FindContainer(id) c := config.FindContainer(id)
if c == nil { if c == nil {
return "", fmt.Errorf("container not found: %d", id) return "", fmt.Errorf("container not found: %d", id)
} }
lxcName := c.LxcName() lxcName := c.LxcName()
newPassword := generateRandomString(16) newPassword := strings.TrimSpace(password)
if newPassword == "" {
newPassword = generateRandomString(16)
}
if c.Status == "running" { if c.Status == "running" {
c.SSHPassword = newPassword c.SSHPassword = newPassword
@@ -1842,13 +1922,11 @@ func (m *Manager) ResetSSHPassword(id int) (string, error) {
return "", err return "", err
} }
rootfsPath := filepath.Join(m.LxcPath, lxcName, "rootfs") rootfsPath := filepath.Join(m.LxcPath, lxcName, "rootfs")
if err := m.preconfigureSSH(rootfsPath, newPassword, c.Template); err != nil { if err := m.preconfigureSSH(rootfsPath, c.Template); err != nil {
return "", fmt.Errorf("failed to configure SSH: %v", err) return "", fmt.Errorf("failed to configure SSH: %v", err)
} }
cmd := m.rootfsCommand(rootfsPath, "sh", "-c", fmt.Sprintf("printf '%%s:%%s\\n' root %s | chpasswd", shellQuote(newPassword))) if err := m.setRootfsPassword(rootfsPath, newPassword); err != nil {
output, err := cmd.CombinedOutput() return "", fmt.Errorf("failed to set password: %v", err)
if err != nil {
return "", fmt.Errorf("failed to set password: %v, output: %s", err, string(output))
} }
c.SSHPassword = newPassword c.SSHPassword = newPassword
config.SaveConfig() config.SaveConfig()
@@ -1857,22 +1935,133 @@ func (m *Manager) ResetSSHPassword(id int) (string, error) {
return newPassword, nil return newPassword, nil
} }
func (m *Manager) rootfsCommand(rootfsPath string, args ...string) *exec.Cmd { func (m *Manager) rootfsCommand(rootfsPath string, args ...string) (*exec.Cmd, error) {
marker := filepath.Join(rootfsPath, ".clicd-unprivileged-shifted") cleanRootfsPath, err := m.safeRootfsPath(rootfsPath)
if err != nil {
return nil, err
}
safeArgs, err := safeRootfsCommandArgs(args)
if err != nil {
return nil, err
}
marker := filepath.Join(cleanRootfsPath, ".clicd-unprivileged-shifted")
if _, err := os.Stat(marker); err == nil { if _, err := os.Stat(marker); err == nil {
uidBase, gidBase, mapErr := unprivilegedIDMap() uidBase, gidBase, mapErr := unprivilegedIDMap()
if mapErr == nil { if mapErr == nil {
cmdArgs := []string{ cmdArgs := []string{
"-m", fmt.Sprintf("u:0:%d:65536", uidBase), "-m", fmt.Sprintf("u:0:%d:65536", uidBase),
"-m", fmt.Sprintf("g:0:%d:65536", gidBase), "-m", fmt.Sprintf("g:0:%d:65536", gidBase),
"--", "chroot", rootfsPath, "--", "chroot", "--", cleanRootfsPath,
} }
cmdArgs = append(cmdArgs, args...) cmdArgs = append(cmdArgs, safeArgs...)
return exec.Command("lxc-usernsexec", cmdArgs...) return exec.Command("lxc-usernsexec", cmdArgs...), nil
} }
} }
cmdArgs := append([]string{rootfsPath}, args...) cmdArgs := append([]string{"--", cleanRootfsPath}, safeArgs...)
return exec.Command("chroot", cmdArgs...) return exec.Command("chroot", cmdArgs...), nil
}
func (m *Manager) runRootfsCommand(rootfsPath string, args ...string) error {
cmd, err := m.rootfsCommand(rootfsPath, args...)
if err != nil {
return err
}
return cmd.Run()
}
func (m *Manager) setRootfsPassword(rootfsPath, password string) error {
if err := validateRootPassword(password); err != nil {
return err
}
cmd, err := m.rootfsCommand(rootfsPath, "chpasswd")
if err != nil {
return err
}
cmd.Stdin = strings.NewReader(rootPasswordInput(password))
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("%v, output: %s", err, string(output))
}
return nil
}
func safeRootfsCommandArgs(args []string) ([]string, error) {
if len(args) == 0 {
return nil, fmt.Errorf("empty rootfs command")
}
allowed := map[string]bool{
"chpasswd": true,
"rc-update": true,
"sh": true,
"systemctl": true,
}
if !allowed[args[0]] || strings.HasPrefix(args[0], "-") || strings.Contains(args[0], "/") {
return nil, fmt.Errorf("rootfs command is not allowed: %s", args[0])
}
for _, arg := range args {
if strings.ContainsRune(arg, '\x00') {
return nil, fmt.Errorf("rootfs command argument contains NUL byte")
}
}
if args[0] == "sh" {
if len(args) != 3 || args[1] != "-c" {
return nil, fmt.Errorf("unsupported rootfs shell invocation")
}
if !isCLICDManagedRootfsScript(args[2]) {
return nil, fmt.Errorf("refusing unmanaged rootfs shell script")
}
}
return append([]string(nil), args...), nil
}
func isCLICDManagedRootfsScript(script string) bool {
return strings.Contains(script, "99-clicd.conf") &&
strings.Contains(script, "install_sshd") &&
!strings.Contains(script, "ROOT_PASSWORD") &&
!strings.Contains(script, "chpasswd")
}
func (m *Manager) safeRootfsPath(rootfsPath string) (string, error) {
if rootfsPath == "" {
return "", fmt.Errorf("empty rootfs path")
}
if !filepath.IsAbs(rootfsPath) {
return "", fmt.Errorf("rootfs path must be absolute: %s", rootfsPath)
}
cleanRootfsPath := filepath.Clean(rootfsPath)
cleanLxcPath, err := filepath.Abs(m.LxcPath)
if err != nil {
return "", fmt.Errorf("failed to resolve LXC path: %v", err)
}
cleanLxcPath = filepath.Clean(cleanLxcPath)
if cleanRootfsPath == cleanLxcPath {
return "", fmt.Errorf("refusing LXC base path as rootfs: %s", cleanRootfsPath)
}
if filepath.Base(cleanRootfsPath) != "rootfs" {
return "", fmt.Errorf("refusing non-rootfs path: %s", cleanRootfsPath)
}
if filepath.Dir(cleanRootfsPath) == cleanLxcPath {
return "", fmt.Errorf("refusing rootfs directly under LXC path: %s", cleanRootfsPath)
}
rel, err := filepath.Rel(cleanLxcPath, cleanRootfsPath)
if err != nil {
return "", fmt.Errorf("failed to validate rootfs path: %v", err)
}
if rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) || filepath.IsAbs(rel) {
return "", fmt.Errorf("refusing unsafe rootfs path: %s", cleanRootfsPath)
}
parts := strings.Split(rel, string(os.PathSeparator))
if len(parts) != 2 || parts[1] != "rootfs" {
return "", fmt.Errorf("refusing nested or malformed rootfs path: %s", cleanRootfsPath)
}
if strings.HasPrefix(parts[0], "-") || !regexp.MustCompile(`^[A-Za-z0-9_.-]+$`).MatchString(parts[0]) {
return "", fmt.Errorf("refusing unsafe container directory name: %s", parts[0])
}
return cleanRootfsPath, nil
} }
func (m *Manager) cleanupContainerStorage(lxcName string) error { func (m *Manager) cleanupContainerStorage(lxcName string) error {
@@ -2114,6 +2303,85 @@ func (m *Manager) ImportExistingClicdContainers() ([]config.Container, error) {
return imported, nil return imported, nil
} }
func (m *Manager) replaceRootfsFromTemplate(lxcName string, tmpl *Template) error {
if tmpl == nil {
return fmt.Errorf("template is nil")
}
tmpName := fmt.Sprintf("clicd-reinstall-%s-%s", lxcName, generateRandomString(8))
tmpDir := filepath.Join(m.LxcPath, tmpName)
if err := os.RemoveAll(tmpDir); err != nil {
return fmt.Errorf("failed to clean temporary reinstall directory: %v", err)
}
defer m.cleanupTemporaryContainer(tmpName)
args := []string{
"-n", tmpName,
"-t", "download",
"--",
"-d", tmpl.Distro,
"-r", tmpl.Release,
"-a", tmpl.Arch,
}
if tmpl.Variant != "" {
args = append(args, "--variant", tmpl.Variant)
}
output, err := exec.Command("lxc-create", args...).CombinedOutput()
if err != nil {
return fmt.Errorf("failed to download replacement rootfs: %v, output: %s", err, string(output))
}
tmpRootfs := filepath.Join(tmpDir, "rootfs")
if !rootfsHasInit(tmpRootfs) {
return fmt.Errorf("downloaded replacement rootfs is invalid: init not found")
}
rootfsPath := filepath.Join(m.LxcPath, lxcName, "rootfs")
if err := m.ensureDiskImageMounted(lxcName); err != nil {
return err
}
m.unmountRootfsChildMounts(rootfsPath)
if err := os.MkdirAll(rootfsPath, 0755); err != nil {
return err
}
if err := removeDirectoryContents(rootfsPath); err != nil {
return fmt.Errorf("failed to clear old rootfs: %v", err)
}
if err := copyRootfsContents(tmpRootfs, rootfsPath); err != nil {
return err
}
if !rootfsHasInit(rootfsPath) {
return fmt.Errorf("replacement rootfs copy failed: init not found")
}
return nil
}
func (m *Manager) cleanupTemporaryContainer(lxcName string) {
exec.Command("lxc-stop", "-n", lxcName, "-k").Run()
exec.Command("lxc-destroy", "-n", lxcName, "-f").Run()
os.RemoveAll(filepath.Join(m.LxcPath, lxcName))
}
func removeDirectoryContents(dir string) error {
entries, err := os.ReadDir(dir)
if err != nil {
return err
}
for _, entry := range entries {
if err := os.RemoveAll(filepath.Join(dir, entry.Name())); err != nil {
return err
}
}
return nil
}
func copyRootfsContents(src, dst string) error {
output, err := exec.Command("cp", "-a", src+string(os.PathSeparator)+".", dst+string(os.PathSeparator)).CombinedOutput()
if err != nil {
return fmt.Errorf("failed to copy replacement rootfs: %v, output: %s", err, string(output))
}
return nil
}
// ReinstallContainer reinstalls the container OS // ReinstallContainer reinstalls the container OS
func (m *Manager) ReinstallContainer(id int, templateID string) error { func (m *Manager) ReinstallContainer(id int, templateID string) error {
c := config.FindContainer(id) c := config.FindContainer(id)
@@ -2137,26 +2405,10 @@ func (m *Manager) ReinstallContainer(id int, templateID string) error {
// Clean port mappings temporarily // Clean port mappings temporarily
m.CleanPortMappings(id) m.CleanPortMappings(id)
// Destroy old LXC but keep config // Download the new OS into a temporary container, then replace only the
exec.Command("lxc-stop", "-n", lxcName, "-k").Run() // existing rootfs. The target container directory and config are preserved.
exec.Command("lxc-destroy", "-n", lxcName, "-f").Run() if err := m.replaceRootfsFromTemplate(lxcName, tmpl); err != nil {
rootfs := filepath.Join(m.LxcPath, lxcName, "rootfs") return err
exec.Command("umount", "-R", "-l", rootfs).Run()
os.RemoveAll(rootfs)
os.Remove(filepath.Join(m.LxcPath, lxcName, "rootfs.img"))
// Create new container with same LXC name (preserves ID)
cmd := exec.Command("lxc-create",
"-n", lxcName,
"-t", "download",
"--",
"-d", tmpl.Distro,
"-r", tmpl.Release,
"-a", tmpl.Arch,
)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("lxc-create failed: %v, output: %s", err, string(output))
} }
if err := m.applyDiskLimit(lxcName, c.DiskGB); err != nil { if err := m.applyDiskLimit(lxcName, c.DiskGB); err != nil {
@@ -2196,15 +2448,15 @@ func (m *Manager) ReinstallContainer(id int, templateID string) error {
if c.SSHPassword == "" { if c.SSHPassword == "" {
c.SSHPassword = generateRandomString(16) c.SSHPassword = generateRandomString(16)
} }
if err := m.preconfigureSSH(rootfsPath, c.SSHPassword, templateID); err != nil { if err := m.preconfigureSSH(rootfsPath, templateID); err != nil {
fmt.Printf("Warning: failed to pre-configure SSH in %s after reinstall: %v\n", lxcName, err) fmt.Printf("Warning: failed to pre-configure SSH in %s after reinstall: %v\n", lxcName, err)
} }
if err := m.shiftRootfsForUnprivileged(lxcName); err != nil { if err := m.shiftRootfsForUnprivileged(lxcName); err != nil {
return err return err
} }
setCmd := m.rootfsCommand(rootfsPath, if err := m.setRootfsPassword(rootfsPath, c.SSHPassword); err != nil {
"sh", "-c", fmt.Sprintf("printf '%%s:%%s\\n' root %s | chpasswd", shellQuote(c.SSHPassword))) fmt.Printf("Warning: failed to set root password in %s after reinstall: %v\n", lxcName, err)
setCmd.Run() }
// Update template and keep everything else the same // Update template and keep everything else the same
c.Template = templateID c.Template = templateID
+90
View File
@@ -0,0 +1,90 @@
package lxc
import (
"os"
"path/filepath"
"reflect"
"strings"
"testing"
)
func TestRootfsCommandAddsSeparatorForAllowedCommand(t *testing.T) {
base := t.TempDir()
rootfs := filepath.Join(base, "ct-1", "rootfs")
if err := os.MkdirAll(rootfs, 0755); err != nil {
t.Fatal(err)
}
m := &Manager{LxcPath: base}
cmd, err := m.rootfsCommand(rootfs, "chpasswd")
if err != nil {
t.Fatalf("rootfsCommand returned error: %v", err)
}
want := []string{"chroot", "--", rootfs, "chpasswd"}
if !reflect.DeepEqual(cmd.Args, want) {
t.Fatalf("cmd.Args = %#v, want %#v", cmd.Args, want)
}
}
func TestRootfsCommandRejectsUnmanagedCommand(t *testing.T) {
base := t.TempDir()
rootfs := filepath.Join(base, "ct-1", "rootfs")
if err := os.MkdirAll(rootfs, 0755); err != nil {
t.Fatal(err)
}
m := &Manager{LxcPath: base}
if _, err := m.rootfsCommand(rootfs, "true"); err == nil {
t.Fatal("rootfsCommand allowed unmanaged command")
}
}
func TestRootfsCommandRejectsLeadingDashContainerName(t *testing.T) {
base := t.TempDir()
rootfs := filepath.Join(base, "-ct", "rootfs")
if err := os.MkdirAll(rootfs, 0755); err != nil {
t.Fatal(err)
}
m := &Manager{LxcPath: base}
if _, err := m.rootfsCommand(rootfs, "chpasswd"); err == nil {
t.Fatal("rootfsCommand allowed leading-dash container name")
}
}
func TestRootfsCommandRejectsUnsafeRootfsPaths(t *testing.T) {
base := t.TempDir()
outside := t.TempDir()
m := &Manager{LxcPath: base}
tests := []struct {
name string
path string
}{
{name: "outside base", path: filepath.Join(outside, "ct-1", "rootfs")},
{name: "base path", path: base},
{name: "not rootfs", path: filepath.Join(base, "ct-1", "not-rootfs")},
{name: "rootfs directly under base", path: filepath.Join(base, "rootfs")},
{name: "relative rootfs", path: filepath.Join("ct-1", "rootfs")},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if _, err := m.rootfsCommand(tc.path, "chpasswd"); err == nil {
t.Fatalf("rootfsCommand(%q) returned nil error", tc.path)
}
})
}
}
func TestSafeRootfsPathRejectsSiblingPrefix(t *testing.T) {
parent := t.TempDir()
base := filepath.Join(parent, "lxc")
siblingRootfs := filepath.Join(parent, "lxc-evil", "ct-1", "rootfs")
m := &Manager{LxcPath: base}
if _, err := m.safeRootfsPath(siblingRootfs); err == nil || !strings.Contains(err.Error(), "unsafe rootfs path") {
t.Fatalf("safeRootfsPath returned %v, want unsafe rootfs path error", err)
}
}
+48 -1
View File
@@ -23,7 +23,7 @@ func corsMiddleware(next http.HandlerFunc) http.HandlerFunc {
w.Header().Set("Vary", "Origin") w.Header().Set("Vary", "Origin")
w.Header().Set("Access-Control-Allow-Credentials", "true") w.Header().Set("Access-Control-Allow-Credentials", "true")
} }
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-API-Key") w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-API-Key")
if r.Method == http.MethodOptions { if r.Method == http.MethodOptions {
@@ -76,15 +76,18 @@ func setupRoutes(mux *http.ServeMux) {
mux.HandleFunc("/api/change-username", corsMiddleware(api.AdminMiddleware(api.HandleAdminUsernameChange))) mux.HandleFunc("/api/change-username", corsMiddleware(api.AdminMiddleware(api.HandleAdminUsernameChange)))
mux.HandleFunc("/api/login-logs", corsMiddleware(api.AdminMiddleware(api.HandleLoginLogs))) mux.HandleFunc("/api/login-logs", corsMiddleware(api.AdminMiddleware(api.HandleLoginLogs)))
mux.HandleFunc("/api/containers", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleContainers)))) mux.HandleFunc("/api/containers", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleContainers))))
mux.HandleFunc("/api/containers/list", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleContainerListAlias))))
mux.HandleFunc("/api/containers/", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleSingleContainer)))) mux.HandleFunc("/api/containers/", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleSingleContainer))))
mux.HandleFunc("/api/templates", corsMiddleware(api.AuthMiddleware(api.HandleTemplates))) mux.HandleFunc("/api/templates", corsMiddleware(api.AuthMiddleware(api.HandleTemplates)))
mux.HandleFunc("/api/images", corsMiddleware(api.AdminMiddleware(api.HandleImages))) mux.HandleFunc("/api/images", corsMiddleware(api.AdminMiddleware(api.HandleImages)))
mux.HandleFunc("/api/images/download", corsMiddleware(api.AdminMiddleware(api.HandleImageDownload))) mux.HandleFunc("/api/images/download", corsMiddleware(api.AdminMiddleware(api.HandleImageDownload)))
mux.HandleFunc("/api/images/cancel", corsMiddleware(api.AdminMiddleware(api.HandleImageCancel)))
mux.HandleFunc("/api/images/delete", corsMiddleware(api.AdminMiddleware(api.HandleImageDelete))) mux.HandleFunc("/api/images/delete", corsMiddleware(api.AdminMiddleware(api.HandleImageDelete)))
mux.HandleFunc("/api/images/toggle", corsMiddleware(api.AdminMiddleware(api.HandleImageToggle))) mux.HandleFunc("/api/images/toggle", corsMiddleware(api.AdminMiddleware(api.HandleImageToggle)))
mux.HandleFunc("/api/images/enabled", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleEnabledImages)))) mux.HandleFunc("/api/images/enabled", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleEnabledImages))))
mux.HandleFunc("/api/dashboard", corsMiddleware(api.AdminMiddleware(api.HandleDashboard))) mux.HandleFunc("/api/dashboard", corsMiddleware(api.AdminMiddleware(api.HandleDashboard)))
mux.HandleFunc("/api/host-info", corsMiddleware(api.AdminMiddleware(api.HandleHostInfo))) mux.HandleFunc("/api/host-info", corsMiddleware(api.AdminMiddleware(api.HandleHostInfo)))
mux.HandleFunc("/api/host-report", corsMiddleware(api.AdminMiddleware(api.HandleHostReport)))
mux.HandleFunc("/api/snapshots", corsMiddleware(api.AdminMiddleware(api.HandleSnapshots))) mux.HandleFunc("/api/snapshots", corsMiddleware(api.AdminMiddleware(api.HandleSnapshots)))
mux.HandleFunc("/api/routing", corsMiddleware(api.AdminMiddleware(api.HandleRouting))) mux.HandleFunc("/api/routing", corsMiddleware(api.AdminMiddleware(api.HandleRouting)))
mux.HandleFunc("/api/ipv6/status", corsMiddleware(api.AdminMiddleware(api.HandleIPv6Status))) mux.HandleFunc("/api/ipv6/status", corsMiddleware(api.AdminMiddleware(api.HandleIPv6Status)))
@@ -102,6 +105,7 @@ func setupRoutes(mux *http.ServeMux) {
mux.HandleFunc("/api/security/check", corsMiddleware(api.AdminMiddleware(api.HandleSecurityCheck))) mux.HandleFunc("/api/security/check", corsMiddleware(api.AdminMiddleware(api.HandleSecurityCheck)))
mux.HandleFunc("/api/security/logs", corsMiddleware(api.AdminMiddleware(api.HandleSecurityLogs))) mux.HandleFunc("/api/security/logs", corsMiddleware(api.AdminMiddleware(api.HandleSecurityLogs)))
mux.HandleFunc("/api/security/summary", corsMiddleware(api.AdminMiddleware(api.HandleContainerSecuritySummary))) mux.HandleFunc("/api/security/summary", corsMiddleware(api.AdminMiddleware(api.HandleContainerSecuritySummary)))
mux.HandleFunc("/api/security/settings", corsMiddleware(api.AdminMiddleware(api.HandleSecuritySettings)))
mux.HandleFunc("/api/ssh-ticket", corsMiddleware(api.AuthMiddleware(api.HandleWebSSHTicket))) mux.HandleFunc("/api/ssh-ticket", corsMiddleware(api.AuthMiddleware(api.HandleWebSSHTicket)))
mux.HandleFunc("/api/ssh", api.HandleWebSSH) // WebSocket mux.HandleFunc("/api/ssh", api.HandleWebSSH) // WebSocket
mux.HandleFunc("/api/vnc-ticket", corsMiddleware(api.AuthMiddleware(api.HandleVNCTicket))) mux.HandleFunc("/api/vnc-ticket", corsMiddleware(api.AuthMiddleware(api.HandleVNCTicket)))
@@ -111,6 +115,49 @@ func setupRoutes(mux *http.ServeMux) {
mux.HandleFunc("/api/api-keys", corsMiddleware(api.AdminMiddleware(api.HandleApiKeys))) mux.HandleFunc("/api/api-keys", corsMiddleware(api.AdminMiddleware(api.HandleApiKeys)))
mux.HandleFunc("/api/api-keys/", corsMiddleware(api.AdminMiddleware(api.HandleApiKeyDelete))) mux.HandleFunc("/api/api-keys/", corsMiddleware(api.AdminMiddleware(api.HandleApiKeyDelete)))
// Versioned external API routes
mux.HandleFunc("/api/v1/dashboard", corsMiddleware(api.AuthMiddleware(api.HandleDashboard)))
mux.HandleFunc("/api/v1/containers", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleContainers))))
mux.HandleFunc("/api/v1/containers/list", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleContainerListAlias))))
mux.HandleFunc("/api/v1/containers/", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleSingleContainer))))
mux.HandleFunc("/api/v1/templates", corsMiddleware(api.AuthMiddleware(api.HandleTemplates)))
mux.HandleFunc("/api/v1/images", corsMiddleware(api.AuthMiddleware(api.HandleImages)))
mux.HandleFunc("/api/v1/images/download", corsMiddleware(api.AuthMiddleware(api.HandleImageDownload)))
mux.HandleFunc("/api/v1/images/cancel", corsMiddleware(api.AuthMiddleware(api.HandleImageCancel)))
mux.HandleFunc("/api/v1/images/delete", corsMiddleware(api.AuthMiddleware(api.HandleImageDelete)))
mux.HandleFunc("/api/v1/images/toggle", corsMiddleware(api.AuthMiddleware(api.HandleImageToggle)))
mux.HandleFunc("/api/v1/images/enabled", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleEnabledImages))))
mux.HandleFunc("/api/v1/host-info", corsMiddleware(api.AuthMiddleware(api.HandleHostInfo)))
mux.HandleFunc("/api/v1/host-report", corsMiddleware(api.AuthMiddleware(api.HandleHostReport)))
mux.HandleFunc("/api/v1/snapshots", corsMiddleware(api.AuthMiddleware(api.ScopeMiddleware("snapshot:read", api.HandleSnapshots))))
mux.HandleFunc("/api/v1/routing", corsMiddleware(api.AuthMiddleware(api.HandleRouting)))
mux.HandleFunc("/api/v1/ipv6/status", corsMiddleware(api.AuthMiddleware(api.HandleIPv6Status)))
mux.HandleFunc("/api/v1/tasks", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleTasks))))
mux.HandleFunc("/api/v1/tasks/", corsMiddleware(api.AuthMiddleware(api.HandleTaskDelete)))
mux.HandleFunc("/api/v1/batch-create", corsMiddleware(api.AuthMiddleware(api.HandleBatchCreate)))
mux.HandleFunc("/api/v1/batch-action", corsMiddleware(api.AuthMiddleware(api.HandleBatchAction)))
mux.HandleFunc("/api/v1/sub-user/create", corsMiddleware(api.AuthMiddleware(api.HandleSubUserCreate)))
mux.HandleFunc("/api/v1/sub-users", corsMiddleware(api.AuthMiddleware(api.HandleSubUserList)))
mux.HandleFunc("/api/v1/sub-users/", corsMiddleware(api.AuthMiddleware(api.HandleSubUserAction)))
mux.HandleFunc("/api/v1/audit-logs", corsMiddleware(api.AuthMiddleware(api.HandleAuditLogs)))
mux.HandleFunc("/api/v1/login-logs", corsMiddleware(api.AuthMiddleware(api.HandleLoginLogs)))
mux.HandleFunc("/api/v1/security/alerts", corsMiddleware(api.AuthMiddleware(api.ScopeMiddleware("security:read", api.HandleSecurityAlerts))))
mux.HandleFunc("/api/v1/security/check", corsMiddleware(api.AuthMiddleware(api.ScopeMiddleware("security:check", api.HandleSecurityCheck))))
mux.HandleFunc("/api/v1/security/logs", corsMiddleware(api.AuthMiddleware(api.ScopeMiddleware("security:read", api.HandleSecurityLogs))))
mux.HandleFunc("/api/v1/security/summary", corsMiddleware(api.AuthMiddleware(api.ScopeMiddleware("security:read", api.HandleContainerSecuritySummary))))
mux.HandleFunc("/api/v1/security/settings", corsMiddleware(api.AuthMiddleware(api.HandleSecuritySettings)))
mux.HandleFunc("/api/v1/ssh-ticket", corsMiddleware(api.AuthMiddleware(api.HandleWebSSHTicket)))
mux.HandleFunc("/api/v1/vnc-ticket", corsMiddleware(api.AuthMiddleware(api.HandleVNCTicket)))
mux.HandleFunc("/api/v1/api-keys", corsMiddleware(api.AuthMiddleware(api.HandleApiKeys)))
mux.HandleFunc("/api/v1/api-keys/", corsMiddleware(api.AuthMiddleware(api.HandleApiKeyDelete)))
mux.HandleFunc("/api/v1/swap", corsMiddleware(api.AuthMiddleware(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
api.HandleSwapInfo(w, r)
return
}
api.HandleSwapManage(w, r)
})))
// Version (public) // Version (public)
mux.HandleFunc("/api/version", corsMiddleware(api.HandleVersion)) mux.HandleFunc("/api/version", corsMiddleware(api.HandleVersion))
+1
View File
@@ -0,0 +1 @@

+1 -9
View File
@@ -1,7 +1,7 @@
package version package version
var ( var (
Version = "1.0.13" Version = "1.1.6"
Repo = "MengMengCode/CLICD" Repo = "MengMengCode/CLICD"
) )
@@ -11,11 +11,3 @@ func Current() string {
} }
return Version return Version
} }
+563 -713
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -1,7 +1,7 @@
{ {
"name": "clicd-frontend", "name": "clicd-frontend",
"private": true, "private": true,
"version": "1.0.0", "version": "1.1.6",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@@ -9,7 +9,7 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@novnc/novnc": "1.6.0", "@novnc/novnc": "1.5.0",
"@xterm/addon-fit": "^0.11.0", "@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0", "@xterm/xterm": "^6.0.0",
"axios": "^1.7.7", "axios": "^1.7.7",
@@ -21,11 +21,11 @@
"devDependencies": { "devDependencies": {
"@types/react": "^18.3.12", "@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1", "@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^5.2.0",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"postcss": "^8.4.49", "postcss": "^8.4.49",
"tailwindcss": "^3.4.15", "tailwindcss": "^3.4.15",
"typescript": "^5.6.3", "typescript": "^5.6.3",
"vite": "^5.4.11" "vite": "^8.0.16"
} }
} }
+2
View File
@@ -8,6 +8,7 @@ import ContainerDetail from './pages/ContainerDetail'
import Security from './pages/Security' import Security from './pages/Security'
import AuditLogs from './pages/AuditLogs' import AuditLogs from './pages/AuditLogs'
import ApiIntegration from './pages/ApiIntegration' import ApiIntegration from './pages/ApiIntegration'
import HostReport from './pages/HostReport'
import Settings from './pages/Settings' import Settings from './pages/Settings'
import ImageManagement from './pages/ImageManagement' import ImageManagement from './pages/ImageManagement'
import Snapshots from './pages/Snapshots' import Snapshots from './pages/Snapshots'
@@ -64,6 +65,7 @@ function App() {
<Route path="routing" element={<Routing />} /> <Route path="routing" element={<Routing />} />
<Route path="audit-logs" element={<AuditLogs />} /> <Route path="audit-logs" element={<AuditLogs />} />
<Route path="api-integration" element={<ApiIntegration />} /> <Route path="api-integration" element={<ApiIntegration />} />
<Route path="host-report" element={<HostReport />} />
<Route path="sub-users" element={<SubUserManagement />} /> <Route path="sub-users" element={<SubUserManagement />} />
<Route path="settings" element={<Settings />} /> <Route path="settings" element={<Settings />} />
</Route> </Route>
+1 -1
View File
@@ -56,7 +56,7 @@ export default function ContainerCard({ container, onRefresh }: ContainerCardPro
{/* Header */} {/* Header */}
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-10 h-10 bg-gray-100 rounded-lg flex items-center justify-center"> <div className="w-10 h-10 flex items-center justify-center">
<Server className="w-5 h-5 text-gray-700" /> <Server className="w-5 h-5 text-gray-700" />
</div> </div>
<div> <div>
+55 -4
View File
@@ -4,6 +4,7 @@ import {
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
Code2, Code2,
Cpu,
Camera, Camera,
LayoutDashboard, LayoutDashboard,
LogOut, LogOut,
@@ -27,6 +28,23 @@ interface SidebarProps {
onToggle: () => void onToggle: () => void
} }
function GitHubIcon({ className = '' }: { className?: string }) {
return (
<svg
className={className}
viewBox="0 0 1024 1024"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path
d="M512 42.666667A464.64 464.64 0 0 0 42.666667 502.186667 460.373333 460.373333 0 0 0 363.52 938.666667c23.466667 4.266667 32-9.813333 32-22.186667v-78.08c-130.56 27.733333-158.293333-61.44-158.293333-61.44a122.026667 122.026667 0 0 0-52.053334-67.413333c-42.666667-28.16 3.413333-27.733333 3.413334-27.733334a98.56 98.56 0 0 1 71.68 47.36 101.12 101.12 0 0 0 136.533333 37.973334 99.413333 99.413333 0 0 1 29.866667-61.44c-104.106667-11.52-213.333333-50.773333-213.333334-226.986667a177.066667 177.066667 0 0 1 47.36-124.16 161.28 161.28 0 0 1 4.693334-121.173333s39.68-12.373333 128 46.933333a455.68 455.68 0 0 1 234.666666 0c89.6-59.306667 128-46.933333 128-46.933333a161.28 161.28 0 0 1 4.693334 121.173333A177.066667 177.066667 0 0 1 810.666667 477.866667c0 176.64-110.08 215.466667-213.333334 226.986666a106.666667 106.666667 0 0 1 32 85.333334v125.866666c0 14.933333 8.533333 26.88 32 22.186667A460.8 460.8 0 0 0 981.333333 502.186667 464.64 464.64 0 0 0 512 42.666667"
fill="currentColor"
/>
</svg>
)
}
export default function Sidebar({ collapsed, onToggle }: SidebarProps) { export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation() const location = useLocation()
@@ -54,6 +72,7 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
const isRoutingPage = location.pathname.startsWith('/routing') const isRoutingPage = location.pathname.startsWith('/routing')
const isAuditLogsPage = location.pathname.startsWith('/audit-logs') const isAuditLogsPage = location.pathname.startsWith('/audit-logs')
const isApiIntegrationPage = location.pathname.startsWith('/api-integration') const isApiIntegrationPage = location.pathname.startsWith('/api-integration')
const isHostReportPage = location.pathname.startsWith('/host-report')
const isSecurityPage = location.pathname.startsWith('/security') const isSecurityPage = location.pathname.startsWith('/security')
const isSettingsPage = location.pathname.startsWith('/settings') const isSettingsPage = location.pathname.startsWith('/settings')
@@ -66,14 +85,14 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
<div className="flex items-center justify-between h-14 px-4 border-b border-gray-200 dark:border-gray-700"> <div className="flex items-center justify-between h-14 px-4 border-b border-gray-200 dark:border-gray-700">
{!collapsed && ( {!collapsed && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="w-7 h-7 bg-gray-100 rounded flex items-center justify-center dark:bg-gray-800"> <div className="w-7 h-7 flex items-center justify-center">
<AppIcon className="w-5 h-5" /> <AppIcon className="w-5 h-5" />
</div> </div>
<span className="font-bold text-black text-sm dark:text-white">CLICD</span> <span className="font-bold text-black text-sm dark:text-white">CLICD</span>
</div> </div>
)} )}
{collapsed && ( {collapsed && (
<div className="w-7 h-7 bg-gray-100 rounded flex items-center justify-center mx-auto dark:bg-gray-800"> <div className="w-7 h-7 flex items-center justify-center mx-auto">
<AppIcon className="w-5 h-5" /> <AppIcon className="w-5 h-5" />
</div> </div>
)} )}
@@ -205,6 +224,18 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
{!collapsed && <span>API </span>} {!collapsed && <span>API </span>}
</button> </button>
<button
onClick={() => navigate('/host-report')}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
isHostReportPage
? 'bg-black text-white dark:bg-white dark:text-black'
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'
}`}
>
<Cpu className="w-4 h-4" />
{!collapsed && <span>宿</span>}
</button>
<button <button
onClick={() => navigate('/settings')} onClick={() => navigate('/settings')}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${ className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
@@ -239,9 +270,29 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
{version && ( {version && (
<div className={`px-3 py-2 text-xs text-gray-400 dark:text-gray-500 ${collapsed ? 'text-center' : ''}`}> <div className={`px-3 py-2 text-xs text-gray-400 dark:text-gray-500 ${collapsed ? 'text-center' : ''}`}>
{collapsed ? ( {collapsed ? (
<span title={`v${version}`}>v{version.split('.').slice(0, 2).join('.')}</span> <a
href="https://github.com/MengMengCode/CLICD"
target="_blank"
rel="noreferrer"
title={`CLICD v${version}`}
className="inline-flex items-center justify-center rounded text-gray-400 transition-colors hover:text-gray-900 dark:text-gray-500 dark:hover:text-white"
>
<GitHubIcon className="h-4 w-4" />
</a>
) : ( ) : (
<span>v{version}</span> <div className="flex min-w-0 items-center gap-2">
<a
href="https://github.com/MengMengCode/CLICD"
target="_blank"
rel="noreferrer"
title="CLICD"
className="inline-flex min-w-0 items-center gap-1 rounded text-gray-500 transition-colors hover:text-gray-950 dark:text-gray-400 dark:hover:text-white"
>
<GitHubIcon className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">CLICD</span>
</a>
<span className="shrink-0">v{version}</span>
</div>
)} )}
</div> </div>
)} )}
+46 -2
View File
@@ -21,6 +21,46 @@ export default function WebVNCViewer({ containerName, onClose }: WebVNCViewerPro
} }
} }
const ensureResizeObserver = () => {
if ('ResizeObserver' in window) return
class FallbackResizeObserver {
private target: Element | null = null
private timer = 0
private lastWidth = -1
private lastHeight = -1
constructor(private callback: ResizeObserverCallback) {}
observe = (target: Element) => {
this.target = target
this.check()
this.timer = window.setInterval(this.check, 250)
window.addEventListener('resize', this.check)
}
unobserve = () => this.disconnect()
disconnect = () => {
if (this.timer) window.clearInterval(this.timer)
this.timer = 0
window.removeEventListener('resize', this.check)
this.target = null
}
private check = () => {
if (!this.target) return
const contentRect = this.target.getBoundingClientRect()
if (contentRect.width === this.lastWidth && contentRect.height === this.lastHeight) return
this.lastWidth = contentRect.width
this.lastHeight = contentRect.height
this.callback([{ target: this.target, contentRect } as ResizeObserverEntry], this as unknown as ResizeObserver)
}
}
;(window as unknown as { ResizeObserver: typeof ResizeObserver }).ResizeObserver = FallbackResizeObserver as unknown as typeof ResizeObserver
}
const connect = async () => { const connect = async () => {
const target = screenRef.current const target = screenRef.current
if (!target) return if (!target) return
@@ -47,7 +87,10 @@ export default function WebVNCViewer({ containerName, onClose }: WebVNCViewerPro
} }
try { try {
const rfb = new RFB(target, getWebVNCUrl(containerName, ticket)) ensureResizeObserver()
const rfb = new RFB(target, getWebVNCUrl(containerName), {
wsProtocols: ['binary', `clicd-vnc-ticket.${ticket}`],
})
rfb.scaleViewport = true rfb.scaleViewport = true
rfb.resizeSession = false rfb.resizeSession = false
rfb.focusOnClick = true rfb.focusOnClick = true
@@ -76,7 +119,8 @@ export default function WebVNCViewer({ containerName, onClose }: WebVNCViewerPro
} catch (err) { } catch (err) {
console.error(err) console.error(err)
setStatus('error') setStatus('error')
setErrorMsg('WebVNC 初始化失败') const message = err instanceof Error && err.message ? `${err.message}` : ''
setErrorMsg(`WebVNC 初始化失败${message}`)
} }
} }
File diff suppressed because it is too large Load Diff
+191 -26
View File
@@ -2,6 +2,7 @@ import { useState, useEffect, useCallback, useRef, type ReactNode } from 'react'
import { useParams, useNavigate } from 'react-router-dom' import { useParams, useNavigate } from 'react-router-dom'
import { import {
ArrowLeft, ArrowLeft,
AlertTriangle,
Camera, Camera,
Clock, Clock,
Copy, Copy,
@@ -143,6 +144,10 @@ export default function ContainerDetail() {
const [resourceEdit, setResourceEdit] = useState({ vcpu: 1, ramMb: 512, ioMbps: 500, bwMbps: 100 }) const [resourceEdit, setResourceEdit] = useState({ vcpu: 1, ramMb: 512, ioMbps: 500, bwMbps: 100 })
const [savingResource, setSavingResource] = useState(false) const [savingResource, setSavingResource] = useState(false)
const [showPassword, setShowPassword] = useState(false) const [showPassword, setShowPassword] = useState(false)
const [showResetPassword, setShowResetPassword] = useState(false)
const [resetPasswordDraft, setResetPasswordDraft] = useState('')
const [resetPasswordResult, setResetPasswordResult] = useState('')
const [resetPasswordSaving, setResetPasswordSaving] = useState(false)
const [showSnapshots, setShowSnapshots] = useState(false) const [showSnapshots, setShowSnapshots] = useState(false)
const [snapshots, setSnapshots] = useState<Snapshot[]>([]) const [snapshots, setSnapshots] = useState<Snapshot[]>([])
const [snapshotQuota, setSnapshotQuota] = useState(3) const [snapshotQuota, setSnapshotQuota] = useState(3)
@@ -299,8 +304,17 @@ export default function ContainerDetail() {
start: '开机中...', stop: '关机中...', restart: '重启中...', delete: '删除中...', reinstall: '重装中...', start: '开机中...', stop: '关机中...', restart: '重启中...', delete: '删除中...', reinstall: '重装中...',
} }
const ensureSubUserCanOperate = async () => {
if (isSubUser && container?.policy_blocked) {
await dialog.alert('策略临时封禁', container.policy_blocked_reason || '虚拟机被策略临时封禁,暂不能执行操作。')
return false
}
return true
}
const handleAction = async (action: string) => { const handleAction = async (action: string) => {
if (!containerIdentifier) return if (!containerIdentifier) return
if (!(await ensureSubUserCanOperate())) return
setActionLoading(action) setActionLoading(action)
try { try {
switch (action) { switch (action) {
@@ -433,20 +447,58 @@ export default function ContainerDetail() {
} }
} }
const generateResetPassword = () => {
const letters = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'
const digits = '23456789'
const symbols = '!@#$%*-_+='
const all = letters + digits + symbols
const pick = (chars: string) => chars[secureRandomInt(chars.length)]
let password = pick(letters) + pick(digits)
while (password.length < 16) password += pick(all)
setResetPasswordDraft(secureShuffle(password.split('')).join(''))
setResetPasswordResult('')
}
const resetPasswordError = (password: string) => {
if (password.length < 8 || password.length > 64) return '密码长度必须为 8-64 位'
if (/\s/.test(password)) return '密码不能包含空白字符'
if (!/[A-Za-z]/.test(password)) return '密码至少需要包含字母'
if (!/\d/.test(password)) return '密码至少需要包含数字'
return ''
}
const handleResetPassword = async () => { const handleResetPassword = async () => {
if (!containerIdentifier || !(await dialog.confirm('重置密码', `确定要重置容器 ${container?.name} 的 SSH 密码吗?`))) return if (!containerIdentifier) return
const password = resetPasswordDraft.trim()
const validationError = resetPasswordError(password)
if (validationError) {
await dialog.alert('密码格式不正确', validationError)
return
}
setResetPasswordSaving(true)
try { try {
const res = await resetSSHPassword(containerIdentifier) const res = await resetSSHPassword(containerIdentifier, password)
if (res.data.success) { if (res.data.success) {
await dialog.alert('密码已重置', `新密码: ${(res.data.data as { password: string })?.password}`) const nextPassword = (res.data.data as { password: string })?.password || password
setResetPasswordResult(nextPassword)
setResetPasswordDraft(nextPassword)
await fetchContainer() await fetchContainer()
} }
} catch (err) { } catch (err: unknown) {
console.error(err) console.error(err)
dialog.alert('密码重置失败', '请稍后重试') const error = err as { response?: { data?: { message?: string } } }
dialog.alert('密码重置失败', error.response?.data?.message || '请稍后重试')
} finally {
setResetPasswordSaving(false)
} }
} }
const openResetPassword = () => {
setResetPasswordDraft('')
setResetPasswordResult('')
setShowResetPassword(true)
}
const handleAssignIPv6 = async () => { const handleAssignIPv6 = async () => {
if (!containerIdentifier) return if (!containerIdentifier) return
setActionLoading('ipv6') setActionLoading('ipv6')
@@ -462,11 +514,13 @@ export default function ContainerDetail() {
} }
const openAddMapping = () => { const openAddMapping = () => {
if (isSubUser && container?.policy_blocked) return
setDraft(emptyDraft) setDraft(emptyDraft)
setShowNat(true) setShowNat(true)
} }
const openEditMapping = (pm: PortMapping, index: number) => { const openEditMapping = (pm: PortMapping, index: number) => {
if (isSubUser && container?.policy_blocked) return
if (isSubUser) { if (isSubUser) {
// Sub-user: only edit container_port in a simple modal // Sub-user: only edit container_port in a simple modal
setDraft({ setDraft({
@@ -489,6 +543,7 @@ export default function ContainerDetail() {
const submitMapping = async (): Promise<boolean> => { const submitMapping = async (): Promise<boolean> => {
if (!containerIdentifier) return false if (!containerIdentifier) return false
if (!(await ensureSubUserCanOperate())) return false
if (draft.index === null && container) { if (draft.index === null && container) {
const currentCount = container.port_mappings?.length || 0 const currentCount = container.port_mappings?.length || 0
const limit = container.port_mapping_limit || Math.max(currentCount, 2) const limit = container.port_mapping_limit || Math.max(currentCount, 2)
@@ -541,6 +596,7 @@ export default function ContainerDetail() {
const removeMapping = async (index: number) => { const removeMapping = async (index: number) => {
if (!containerIdentifier || !(await dialog.confirm('删除映射', '确定要删除这条映射规则吗?'))) return if (!containerIdentifier || !(await dialog.confirm('删除映射', '确定要删除这条映射规则吗?'))) return
if (!(await ensureSubUserCanOperate())) return
try { try {
await deletePortMapping(containerIdentifier, index) await deletePortMapping(containerIdentifier, index)
await fetchContainer() await fetchContainer()
@@ -553,6 +609,7 @@ export default function ContainerDetail() {
const handleCreateSnapshot = async () => { const handleCreateSnapshot = async () => {
if (!containerIdentifier) return if (!containerIdentifier) return
if (!(await ensureSubUserCanOperate())) return
if (isSubUser && snapshots.length >= snapshotQuota) { if (isSubUser && snapshots.length >= snapshotQuota) {
await dialog.alert('快照配额已满', '已达到管理员设置的快照配额,请先删除旧快照。') await dialog.alert('快照配额已满', '已达到管理员设置的快照配额,请先删除旧快照。')
return return
@@ -577,6 +634,7 @@ export default function ContainerDetail() {
} }
const openSnapshotSchedule = () => { const openSnapshotSchedule = () => {
if (isSubUser && container?.policy_blocked) return
setSnapshotScheduleDraft({ setSnapshotScheduleDraft({
intervalHours: Math.max(snapshotSchedule?.interval_hours || 24, 24), intervalHours: Math.max(snapshotSchedule?.interval_hours || 24, 24),
time: snapshotSchedule?.time || '03:00', time: snapshotSchedule?.time || '03:00',
@@ -586,6 +644,7 @@ export default function ContainerDetail() {
const saveSnapshotSchedule = async (enabled: boolean) => { const saveSnapshotSchedule = async (enabled: boolean) => {
if (!containerIdentifier) return if (!containerIdentifier) return
if (!(await ensureSubUserCanOperate())) return
const intervalHours = snapshotScheduleDraft.intervalHours const intervalHours = snapshotScheduleDraft.intervalHours
const scheduleTime = snapshotScheduleDraft.time || '03:00' const scheduleTime = snapshotScheduleDraft.time || '03:00'
if (enabled && intervalHours < 24) { if (enabled && intervalHours < 24) {
@@ -625,6 +684,7 @@ export default function ContainerDetail() {
const handleDeleteSnapshot = async (snapshot: Snapshot) => { const handleDeleteSnapshot = async (snapshot: Snapshot) => {
if (!containerIdentifier) return if (!containerIdentifier) return
if (!(await ensureSubUserCanOperate())) return
if (!(await dialog.confirm('删除快照', `确定删除 ${snapshot.created_at} 的快照吗?`))) return if (!(await dialog.confirm('删除快照', `确定删除 ${snapshot.created_at} 的快照吗?`))) return
setSnapshotBusy(snapshot.id) setSnapshotBusy(snapshot.id)
try { try {
@@ -640,6 +700,7 @@ export default function ContainerDetail() {
const handleRestoreSnapshot = async (snapshot: Snapshot) => { const handleRestoreSnapshot = async (snapshot: Snapshot) => {
if (!containerIdentifier) return if (!containerIdentifier) return
if (!(await ensureSubUserCanOperate())) return
if (!(await dialog.confirm('恢复快照', `确定恢复到 ${snapshot.created_at} 的快照吗?当前容器数据会被覆盖。`))) return if (!(await dialog.confirm('恢复快照', `确定恢复到 ${snapshot.created_at} 的快照吗?当前容器数据会被覆盖。`))) return
setSnapshotBusy(snapshot.id) setSnapshotBusy(snapshot.id)
try { try {
@@ -681,6 +742,9 @@ export default function ContainerDetail() {
const isWindows = container.template?.includes('windows') const isWindows = container.template?.includes('windows')
const canOpenVNC = isKVM && isRunning const canOpenVNC = isKVM && isRunning
const isExpired = container.expires_at ? new Date(container.expires_at) < new Date() : false const isExpired = container.expires_at ? new Date(container.expires_at) < new Date() : false
const isPolicyBlocked = !!container.policy_blocked
const isSubUserPolicyBlocked = isSubUser && isPolicyBlocked
const policyBlockedText = container.policy_blocked_reason || '虚拟机被策略临时封禁'
const publicHost = hostInfo?.network.public_ipv4 || PUBLIC_HOST const publicHost = hostInfo?.network.public_ipv4 || PUBLIC_HOST
const maxVCPU = hostInfo?.cpu.cores || 64 const maxVCPU = hostInfo?.cpu.cores || 64
const maxRAMMB = hostInfo?.ram.total_mb ? Number(hostInfo.ram.total_mb) : undefined const maxRAMMB = hostInfo?.ram.total_mb ? Number(hostInfo.ram.total_mb) : undefined
@@ -703,7 +767,7 @@ export default function ContainerDetail() {
const diskIOBps = (usage?.disk_read_bps || 0) + (usage?.disk_write_bps || 0) const diskIOBps = (usage?.disk_read_bps || 0) + (usage?.disk_write_bps || 0)
const mappingCount = container.port_mappings?.length || 0 const mappingCount = container.port_mappings?.length || 0
const mappingLimit = container.port_mapping_limit || Math.max(mappingCount, 2) const mappingLimit = container.port_mapping_limit || Math.max(mappingCount, 2)
const canAddMapping = isSubUser ? mappingCount < mappingLimit : true const canAddMapping = isSubUser ? mappingCount < mappingLimit && !isSubUserPolicyBlocked : true
const managementUrl = subUser?.access_code const managementUrl = subUser?.access_code
? `${window.location.origin}/login?code=${encodeURIComponent(subUser.access_code)}` ? `${window.location.origin}/login?code=${encodeURIComponent(subUser.access_code)}`
: '' : ''
@@ -760,7 +824,7 @@ export default function ContainerDetail() {
<div className="bg-white border border-gray-200 rounded-lg p-5"> <div className="bg-white border border-gray-200 rounded-lg p-5">
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-4"> <div className="flex items-start gap-4">
<div className="w-14 h-14 bg-slate-100 rounded-lg flex items-center justify-center"> <div className="w-14 h-14 flex items-center justify-center">
{getTemplateIcon(container.template || '') || <Cpu className="w-7 h-7 text-slate-700" />} {getTemplateIcon(container.template || '') || <Cpu className="w-7 h-7 text-slate-700" />}
</div> </div>
<div> <div>
@@ -774,34 +838,35 @@ export default function ContainerDetail() {
<InfoTag color="emerald"> {container.ip || '-'}</InfoTag> <InfoTag color="emerald"> {container.ip || '-'}</InfoTag>
<InfoTag color="amber">NAT {mappingCount} </InfoTag> <InfoTag color="amber">NAT {mappingCount} </InfoTag>
<InfoTag color="violet">{isWindows ? 'RDP' : 'SSH'} {publicHost}:{container.ssh_port}</InfoTag> <InfoTag color="violet">{isWindows ? 'RDP' : 'SSH'} {publicHost}:{container.ssh_port}</InfoTag>
{isPolicyBlocked && <InfoTag color="red"></InfoTag>}
</div> </div>
</div> </div>
</div> </div>
<div className="flex items-center gap-1.5 flex-wrap justify-end"> <div className="flex items-center gap-1.5 flex-wrap justify-end">
{!isRunning ? ( {!isRunning ? (
<ActionButton dark disabled={!!taskStatus || isExpired} onClick={() => handleAction('start')}> <ActionButton dark disabled={!!taskStatus || isExpired || isSubUserPolicyBlocked} onClick={() => handleAction('start')}>
<Play className="w-3.5 h-3.5" /> <Play className="w-3.5 h-3.5" />
{isExpired ? '已到期' : taskStatus === 'start' ? taskActionLabels['start'] : '开机'} {isSubUserPolicyBlocked ? '已封禁' : isExpired ? '已到期' : taskStatus === 'start' ? taskActionLabels['start'] : '开机'}
</ActionButton> </ActionButton>
) : ( ) : (
<> <>
<ActionButton disabled={!!taskStatus || isExpired} onClick={() => handleAction('stop')}> <ActionButton disabled={!!taskStatus || isExpired || isSubUserPolicyBlocked} onClick={() => handleAction('stop')}>
<Square className="w-3.5 h-3.5" /> <Square className="w-3.5 h-3.5" />
{isExpired ? '已到期' : taskStatus === 'stop' ? taskActionLabels['stop'] : '关机'} {isSubUserPolicyBlocked ? '已封禁' : isExpired ? '已到期' : taskStatus === 'stop' ? taskActionLabels['stop'] : '关机'}
</ActionButton> </ActionButton>
<ActionButton disabled={!!taskStatus || isExpired} onClick={() => handleAction('restart')}> <ActionButton disabled={!!taskStatus || isExpired || isSubUserPolicyBlocked} onClick={() => handleAction('restart')}>
<RefreshCw className="w-3.5 h-3.5" /> <RefreshCw className="w-3.5 h-3.5" />
{isExpired ? '已到期' : taskStatus === 'restart' ? taskActionLabels['restart'] : '重启'} {isSubUserPolicyBlocked ? '已封禁' : isExpired ? '已到期' : taskStatus === 'restart' ? taskActionLabels['restart'] : '重启'}
</ActionButton> </ActionButton>
{!isWindows && ( {!isWindows && (
<ActionButton dark onClick={() => setShowSSH(true)}> <ActionButton dark disabled={isSubUserPolicyBlocked} onClick={() => setShowSSH(true)}>
<TerminalSquare className="w-3.5 h-3.5" /> <TerminalSquare className="w-3.5 h-3.5" />
WebSSH WebSSH
</ActionButton> </ActionButton>
)} )}
{isKVM && ( {isKVM && (
<ActionButton dark disabled={!canOpenVNC} onClick={() => setShowVNC(true)}> <ActionButton dark disabled={!canOpenVNC || isSubUserPolicyBlocked} onClick={() => setShowVNC(true)}>
<Monitor className="w-3.5 h-3.5" /> <Monitor className="w-3.5 h-3.5" />
WebVNC WebVNC
</ActionButton> </ActionButton>
@@ -815,12 +880,12 @@ export default function ContainerDetail() {
</ActionButton> </ActionButton>
)} )}
<> <>
<ActionButton onClick={() => setShowNat(true)}> <ActionButton disabled={isSubUserPolicyBlocked} onClick={() => setShowNat(true)}>
<Settings className="w-3.5 h-3.5" /> <Settings className="w-3.5 h-3.5" />
NAT NAT
</ActionButton> </ActionButton>
</> </>
<ActionButton onClick={() => setShowSnapshots(true)} disabled={!!taskStatus || !!snapshotBusy}> <ActionButton onClick={() => setShowSnapshots(true)} disabled={!!taskStatus || !!snapshotBusy || isSubUserPolicyBlocked}>
<Camera className="w-3.5 h-3.5" /> <Camera className="w-3.5 h-3.5" />
</ActionButton> </ActionButton>
@@ -840,9 +905,34 @@ export default function ContainerDetail() {
</div> </div>
</div> </div>
{isSubUserPolicyBlocked && (
<div className="flex items-start gap-3 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<div>
<div className="font-medium"></div>
<div className="mt-1 text-xs text-red-600">{policyBlockedText}</div>
</div>
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-5"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-5">
<Panel title="连接信息"> <Panel
{isWindows ? ( title="连接信息"
extra={!isSubUser && !isWindows && !isSubUserPolicyBlocked ? (
<button
onClick={openResetPassword}
className="inline-flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs text-gray-600 hover:bg-gray-100 hover:text-black"
>
<Key className="w-3.5 h-3.5" />
SSH
</button>
) : undefined}
>
{isSubUserPolicyBlocked ? (
<div className="rounded-md border border-red-100 bg-red-50 px-3 py-2 text-sm text-red-700">
</div>
) : isWindows ? (
<> <>
<PlainRow label="RDP 地址" value={`${publicHost}:${container.ssh_port}`} mono /> <PlainRow label="RDP 地址" value={`${publicHost}:${container.ssh_port}`} mono />
<PlainRow label="用户名" value="Administrator" mono /> <PlainRow label="用户名" value="Administrator" mono />
@@ -888,12 +978,6 @@ export default function ContainerDetail() {
)} )}
</div> </div>
</div> </div>
{!isSubUser && (
<button onClick={handleResetPassword} className="inline-flex items-center gap-1.5 text-xs text-gray-600 hover:text-black">
<Key className="w-3 h-3" />
SSH
</button>
)}
</> </>
)} )}
</Panel> </Panel>
@@ -1046,6 +1130,60 @@ export default function ContainerDetail() {
<ResourceStatsPanel range={range} onRangeChange={setRange} onRefresh={() => { fetchContainer(); fetchUsage() }} charts={charts} /> <ResourceStatsPanel range={range} onRangeChange={setRange} onRefresh={() => { fetchContainer(); fetchUsage() }} charts={charts} />
{showResetPassword && (
<Modal title="重置 SSH 密码" onClose={() => setShowResetPassword(false)}>
<div className="space-y-4">
<div>
<label className="block text-xs text-gray-500 mb-1"> SSH </label>
<div className="flex gap-2">
<input
type="text"
value={resetPasswordDraft}
onChange={(e) => { setResetPasswordDraft(e.target.value); setResetPasswordResult('') }}
placeholder="请输入 8-64 位,至少包含字母和数字"
className={inputClass}
/>
<button
type="button"
onClick={generateResetPassword}
className="px-3 py-2 border border-gray-300 rounded-md text-gray-600 hover:bg-gray-50 hover:text-black"
title="生成随机密码"
>
<RefreshCw className="w-4 h-4" />
</button>
</div>
{resetPasswordDraft && resetPasswordError(resetPasswordDraft) && (
<p className="mt-1 text-xs text-red-600">{resetPasswordError(resetPasswordDraft)}</p>
)}
</div>
{resetPasswordResult && (
<div className="p-3 bg-green-50 border border-green-200 rounded-md">
<div className="text-xs text-green-700 mb-1"></div>
<div className="flex items-center justify-between gap-2">
<span className="font-mono text-sm text-green-900 break-all">{resetPasswordResult}</span>
<button onClick={() => copyText(resetPasswordResult)} className="p-1 text-green-700 hover:text-green-900 rounded" title="复制">
<Copy className="w-4 h-4" />
</button>
</div>
</div>
)}
<p className="text-xs text-gray-500 leading-relaxed">
Linux LXC/KVM root SSH KVM guest agent SSH
</p>
<div className="flex justify-end gap-2 pt-2">
<button onClick={() => setShowResetPassword(false)} className="px-4 py-2 text-sm text-gray-600 border border-gray-200 rounded-md hover:bg-gray-50"></button>
<button
onClick={handleResetPassword}
disabled={resetPasswordSaving || !resetPasswordDraft || !!resetPasswordError(resetPasswordDraft)}
className="px-4 py-2 text-sm bg-black text-white rounded-md hover:bg-gray-800 disabled:opacity-50"
>
{resetPasswordSaving ? '修改中...' : '确认修改'}
</button>
</div>
</div>
</Modal>
)}
{showSSH && ( {showSSH && (
<Modal title={`WebSSH - ${container.name}`} onClose={() => setShowSSH(false)} wide> <Modal title={`WebSSH - ${container.name}`} onClose={() => setShowSSH(false)} wide>
<div className="h-[70vh] min-h-[520px]"> <div className="h-[70vh] min-h-[520px]">
@@ -1507,13 +1645,14 @@ function StatusBadge({ running }: { running: boolean }) {
) )
} }
function InfoTag({ color, children }: { color: 'blue' | 'emerald' | 'amber' | 'violet' | 'slate'; children: ReactNode }) { function InfoTag({ color, children }: { color: 'blue' | 'emerald' | 'amber' | 'violet' | 'slate' | 'red'; children: ReactNode }) {
const classes = { const classes = {
blue: 'bg-blue-50 text-blue-700 border-blue-100', blue: 'bg-blue-50 text-blue-700 border-blue-100',
emerald: 'bg-emerald-50 text-emerald-700 border-emerald-100', emerald: 'bg-emerald-50 text-emerald-700 border-emerald-100',
amber: 'bg-amber-50 text-amber-700 border-amber-100', amber: 'bg-amber-50 text-amber-700 border-amber-100',
violet: 'bg-violet-50 text-violet-700 border-violet-100', violet: 'bg-violet-50 text-violet-700 border-violet-100',
slate: 'bg-slate-50 text-slate-700 border-slate-100', slate: 'bg-slate-50 text-slate-700 border-slate-100',
red: 'bg-red-50 text-red-700 border-red-100',
} }
return <span className={`px-1.5 py-0.5 border rounded text-[11px] whitespace-nowrap ${classes[color]}`}>{children}</span> return <span className={`px-1.5 py-0.5 border rounded text-[11px] whitespace-nowrap ${classes[color]}`}>{children}</span>
} }
@@ -1915,6 +2054,32 @@ function TrafficBar({ container }: { container: Container }) {
) )
} }
function secureRandomInt(maxExclusive: number) {
if (!Number.isSafeInteger(maxExclusive) || maxExclusive <= 0) {
throw new Error('invalid random range')
}
const values = new Uint32Array(1)
const maxUint32 = 0x100000000
const limit = Math.floor(maxUint32 / maxExclusive) * maxExclusive
let value = 0
do {
crypto.getRandomValues(values)
value = values[0]
} while (value >= limit)
return value % maxExclusive
}
function secureShuffle<T>(items: T[]) {
const next = [...items]
for (let i = next.length - 1; i > 0; i--) {
const j = secureRandomInt(i + 1)
const value = next[i]
next[i] = next[j]
next[j] = value
}
return next
}
function getTemplateIcon(id: string): ReactNode { function getTemplateIcon(id: string): ReactNode {
const size = 'w-6 h-6' const size = 'w-6 h-6'
id = id.startsWith('kvm-') ? id.slice(4) : id id = id.startsWith('kvm-') ? id.slice(4) : id
+12 -2
View File
@@ -392,6 +392,7 @@ export default function Containers() {
const isRunning = container.status === 'running' const isRunning = container.status === 'running'
const task = (container.id > 0 ? taskStatusMap[container.id] : taskNameMap[container.name]) || container.createTask const task = (container.id > 0 ? taskStatusMap[container.id] : taskNameMap[container.name]) || container.createTask
const isPlaceholder = !!container.isPlaceholder const isPlaceholder = !!container.isPlaceholder
const isPolicyBlocked = !!container.policy_blocked
const usage = usageByName[container.name] const usage = usageByName[container.name]
const isKVM = (container.virtualization || 'lxc') === 'kvm' const isKVM = (container.virtualization || 'lxc') === 'kvm'
@@ -436,7 +437,7 @@ export default function Containers() {
</button> </button>
</td> </td>
<td className="px-2.5 py-2 align-top"> <td className="px-2.5 py-2 align-top">
<StatusBadge running={isRunning} task={task} placeholder={isPlaceholder} /> <StatusBadge running={isRunning} task={task} placeholder={isPlaceholder} policyBlocked={isPolicyBlocked} />
</td> </td>
<td className="px-2.5 py-2 align-top text-xs text-gray-600 whitespace-nowrap"> <td className="px-2.5 py-2 align-top text-xs text-gray-600 whitespace-nowrap">
<span className="inline-flex items-center gap-1"> <span className="inline-flex items-center gap-1">
@@ -580,8 +581,17 @@ type DisplayContainer = Container & {
createTask?: Task createTask?: Task
} }
function StatusBadge({ running, task, placeholder }: { running: boolean; task?: Task; placeholder?: boolean }) { function StatusBadge({ running, task, placeholder, policyBlocked }: { running: boolean; task?: Task; placeholder?: boolean; policyBlocked?: boolean }) {
const baseClass = "inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium whitespace-nowrap" const baseClass = "inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium whitespace-nowrap"
if (policyBlocked) {
return (
<span className={`${baseClass} bg-red-50 text-red-700`}>
<span className="w-1.5 h-1.5 rounded-full bg-red-500"></span>
</span>
)
}
if (task?.status === 'failed') { if (task?.status === 'failed') {
return ( return (
<span className={`${baseClass} bg-red-50 text-red-700`}> <span className={`${baseClass} bg-red-50 text-red-700`}>
+328
View File
@@ -0,0 +1,328 @@
import { ReactNode, useCallback, useEffect, useState } from 'react'
import {
Activity,
CheckCircle2,
Cpu,
HardDrive,
MemoryStick,
RefreshCw,
XCircle,
} from 'lucide-react'
import { getHostReport, HostProbeReport } from '../services/api'
export default function HostReport() {
const [report, setReport] = useState<HostProbeReport | null>(null)
const [loading, setLoading] = useState(true)
const fetchReport = useCallback(async () => {
setLoading(true)
try {
const res = await getHostReport()
setReport(res.data.data || null)
} catch (err) {
console.error(err)
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchReport()
}, [fetchReport])
return (
<div className="space-y-6">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h1 className="text-2xl font-bold text-black">宿</h1>
<p className="mt-1 text-sm text-gray-500"></p>
</div>
<button onClick={fetchReport} disabled={loading} className="inline-flex items-center gap-1.5 rounded-md border border-gray-200 px-3 py-2 text-sm text-gray-600 hover:bg-gray-50 disabled:opacity-50">
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
</button>
</div>
{loading && !report ? (
<div className="rounded-lg border border-gray-200 bg-white py-14 text-center text-sm text-gray-400">宿...</div>
) : !report ? (
<div className="rounded-lg border border-gray-200 bg-white py-14 text-center text-sm text-gray-400">宿</div>
) : (
<div className="space-y-5">
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<ProbeMetric icon={<Cpu className="h-4 w-4" />} label="CPU" value={report.cpu.model || 'Unknown'} sub={`${report.cpu.cores} 核 / ${report.cpu.threads} 线程`} />
<ProbeMetric icon={<MemoryStick className="h-4 w-4" />} label="RAM" value={formatMB(report.memory.total_mb)} sub={`${formatMB(report.memory.used_mb)} 已用`} />
<ProbeMetric icon={<HardDrive className="h-4 w-4" />} label="DISK" value={`${report.disks.length} 块硬盘`} sub={report.disks.map(d => d.type).filter(Boolean).join(' / ') || 'Unknown'} />
<ProbeMetric icon={<Activity className="h-4 w-4" />} label="运行状态" value={report.system.uptime_text} sub={`${report.system.process_count} 个进程`} />
</div>
<ProbeSection title="系统概览">
<ProbeRows rows={[
['主机名', report.hostname],
['操作系统', report.os],
['内核', report.kernel],
['生成时间', report.generated_at],
['CPU 架构', report.cpu.architecture],
['CPU 虚拟化指令', report.cpu.virtualization ? `支持 (${report.cpu.virtualization_key})` : '未检测到'],
['CPU 核显', report.cpu.has_integrated_gpu ? '检测到' : '未检测到'],
['显卡', report.gpus.length ? `${report.gpus.length}` : '未检测到'],
['运行能力', runtimeModeLabel(report.runtime.support_mode)],
['KVM 嵌套虚拟化', `${report.runtime.nested_virtualization ? '支持' : '未检测到'} (${report.runtime.nested_detail || '-'})`],
]} />
</ProbeSection>
<ProbeSection title="公网与路由">
<ProbeRows rows={[
['公网 IPv4', report.public_ipv4.length ? report.public_ipv4.join('\n') : '未检测到'],
['IPv4 地址', report.ipv4_addresses?.length ? report.ipv4_addresses.map(formatIPv4Address).join('\n') : '未检测到'],
['IPv4 段', report.ipv4_prefixes?.length ? report.ipv4_prefixes.map(formatIPv4Prefix).join('\n') : '未检测到'],
['IPv6 地址', report.ipv6_addresses.length ? report.ipv6_addresses.map(ip => `${ip.address}/${ip.prefix_len} (${ip.interface})`).join('\n') : '未检测到'],
['IPv6 段', report.ipv6_prefixes?.length ? report.ipv6_prefixes.map(formatIPv6Prefix).join('\n') : '未检测到'],
['网关', report.gateways.length ? report.gateways.map(g => `${g.family}: ${g.gateway || '-'} dev ${g.interface || '-'}`).join('\n') : '未检测到'],
]} />
</ProbeSection>
<ProbeTable
title="内存条"
empty="未检测到内存条明细,可能缺少 dmidecode 或权限受限"
headers={['插槽', '容量', '类型', '频率', '厂商', '型号/序列号']}
rows={(report.memory.modules || []).map(m => [
m.locator || '-',
m.size || '-',
m.type || '-',
m.speed || '-',
m.manufacturer || '-',
[m.part_number, m.serial_number].filter(Boolean).join(' / ') || '-',
])}
/>
<ProbeTable
title="硬盘与健康"
empty="未检测到硬盘"
headers={['设备', '型号', '容量', '类型', '挂载点', '健康', '寿命', '通电', '读取', '写入', '命令数', '擦写']}
rows={report.disks.map(d => [
`${d.path || d.name}\n${d.serial || ''}`,
d.model || '-',
formatBytes(d.size_bytes),
d.type || (d.rotational ? 'HDD' : 'SSD'),
d.mountpoints?.length ? d.mountpoints.join('\n') : '-',
`${diskHealthLabel(d.health)}\n${d.health_detail || ''}`,
formatLifeUsed(d.smart?.life_used_percent),
d.smart?.power_on_hours ? `${d.smart.power_on_hours} 小时\n${formatPowerOnDays(d.smart.power_on_hours)}` : '-',
formatBytes(d.smart?.read_data_bytes || 0),
formatBytes(d.smart?.written_data_bytes || 0),
formatCommands(d.smart?.read_commands, d.smart?.write_commands),
formatWear(d.smart?.wear_leveling_count, d.smart?.erase_count, d.smart?.power_cycle_count),
])}
/>
<ProbeTable
title="网卡"
empty="未检测到网卡"
headers={['网卡', '状态', '驱动/速率', 'MAC', 'IPv4', 'IPv6']}
rows={report.network_interfaces.map(n => [
`${n.name}\n${n.model || ''}`,
n.state || '-',
`${n.driver || '-'}\n${n.speed_mbps > 0 ? `${n.speed_mbps} Mbps` : '-'}`,
n.mac || '-',
n.ipv4?.length ? n.ipv4.map(ip => `${ip.address}/${ip.prefix_len}`).join('\n') : '-',
n.ipv6?.length ? n.ipv6.map(ip => `${ip.address}/${ip.prefix_len} ${ip.scope}`).join('\n') : '-',
])}
/>
<ProbeTable
title="显卡"
empty="未检测到显卡"
headers={['名称', '厂商', '类型', '驱动']}
rows={report.gpus.map(g => [g.name, g.vendor || '-', gpuTypeLabel(g.type), g.driver || '-'])}
/>
<ProbeSection title="环境支持">
<div className="grid gap-2 md:grid-cols-2">
{report.environment.map(item => (
<div key={item.key} className="flex items-start gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2">
{item.ok ? <CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0 text-green-600" /> : <XCircle className={`mt-0.5 h-4 w-4 shrink-0 ${item.required ? 'text-red-600' : 'text-amber-600'}`} />}
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2 text-xs font-medium text-gray-800">
<span>{item.label}</span>
<span className={`rounded px-1.5 py-0.5 text-[10px] ${item.required ? 'bg-gray-100 text-gray-600' : 'bg-blue-50 text-blue-700'}`}>
{item.required ? '必要' : '可选'}
</span>
</div>
<div className="mt-1 break-all font-mono text-[11px] text-gray-500">{item.detail || '-'}</div>
</div>
</div>
))}
</div>
</ProbeSection>
</div>
)}
</div>
)
}
function ProbeMetric({ icon, label, value, sub }: { icon: ReactNode; label: string; value: string; sub: string }) {
return (
<div className="rounded-lg border border-gray-200 bg-white px-3 py-3">
<div className="mb-2 flex items-center gap-2 text-xs font-medium text-gray-500">
{icon}
{label}
</div>
<div className="line-clamp-2 break-words text-sm font-semibold text-gray-900" title={value}>{value}</div>
<div className="mt-1 truncate text-xs text-gray-500" title={sub}>{sub}</div>
</div>
)
}
function ProbeSection({ title, children }: { title: string; children: ReactNode }) {
return (
<section>
<h2 className="mb-2 text-sm font-semibold text-black">{title}</h2>
{children}
</section>
)
}
function ProbeRows({ rows }: { rows: Array<[string, string]> }) {
return (
<div className="overflow-hidden rounded-lg border border-gray-200 bg-white">
{rows.map(([label, value]) => (
<div key={label} className="grid gap-2 border-b border-gray-100 px-3 py-2 text-xs last:border-b-0 md:grid-cols-[160px_1fr]">
<div className="font-medium text-gray-500">{label}</div>
<div className="whitespace-pre-wrap break-words font-mono text-gray-800">{value || '-'}</div>
</div>
))}
</div>
)
}
function ProbeTable({ title, headers, rows, empty }: { title: string; headers: string[]; rows: string[][]; empty: string }) {
return (
<section>
<h2 className="mb-2 text-sm font-semibold text-black">{title}</h2>
{rows.length === 0 ? (
<div className="rounded-lg border border-gray-200 bg-white px-3 py-3 text-xs text-gray-400">{empty}</div>
) : (
<div className="overflow-x-auto rounded-lg border border-gray-200 bg-white">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-gray-100 bg-gray-50 text-left text-gray-500">
{headers.map(header => <th key={header} className="px-3 py-2 font-medium">{header}</th>)}
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{rows.map((row, rowIndex) => (
<tr key={rowIndex} className="align-top">
{row.map((cell, cellIndex) => (
<td key={cellIndex} className="max-w-[280px] whitespace-pre-wrap break-words px-3 py-2 text-gray-700">
{cell || '-'}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
)
}
function formatIPv4Address(ip: HostProbeReport['ipv4_addresses'][number]) {
return `${ip.address}/${ip.prefix_len} (${ip.interface})`
}
function formatIPv4Prefix(prefix: HostProbeReport['ipv4_prefixes'][number]) {
const parts = [
prefix.prefix || '-',
prefix.subnet_mask ? `mask ${prefix.subnet_mask}` : '',
prefix.gateway ? `via ${prefix.gateway}` : '',
prefix.interface ? `dev ${prefix.interface}` : '',
prefix.source ? `[${prefix.source}]` : '',
].filter(Boolean)
return parts.join(' ')
}
function formatIPv6Prefix(prefix: HostProbeReport['ipv6_prefixes'][number]) {
const value = prefix.prefix || prefix.address || '-'
const cidr = value.includes('/') || !prefix.prefix_len ? value : `${value}/${prefix.prefix_len}`
return `${cidr} via ${prefix.gateway || '-'}`
}
function formatMB(value: number) {
if (!value) return '-'
if (value >= 1024) return `${(value / 1024).toFixed(1)} GB`
return `${value} MB`
}
function formatBytes(value: number) {
if (!value) return '-'
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']
let next = value
let index = 0
while (next >= 1024 && index < units.length - 1) {
next /= 1024
index++
}
return `${next.toFixed(index === 0 ? 0 : 1)} ${units[index]}`
}
function formatLifeUsed(value?: number) {
if (value === undefined || value === null) return '-'
return `${value}% 已用\n${Math.max(0, 100 - value)}% 剩余`
}
function formatPowerOnDays(hours: number) {
const days = Math.floor(hours / 24)
const rest = hours % 24
return days > 0 ? `${days}${rest} 小时` : `${hours} 小时`
}
function formatCommands(read?: number, write?: number) {
if (!read && !write) return '-'
return `${formatCount(read || 0)}\n写 ${formatCount(write || 0)}`
}
function formatCount(value: number) {
if (!value) return '-'
if (value >= 1_000_000_000) return `${(value / 1_000_000_000).toFixed(1)}B`
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`
if (value >= 1_000) return `${(value / 1_000).toFixed(1)}K`
return `${value}`
}
function formatWear(wear?: string, erase?: string, powerCycles?: number) {
const rows: string[] = []
if (wear) rows.push(`磨损 ${wear}`)
if (erase) rows.push(`擦写 ${erase}`)
if (powerCycles) rows.push(`启停 ${powerCycles}`)
return rows.length ? rows.join('\n') : '-'
}
function runtimeModeLabel(value: string) {
switch (value) {
case 'kvm_lxc':
return '支持 KVM + LXC'
case 'lxc_only':
return '仅支持 LXC'
default:
return '未满足运行环境'
}
}
function diskHealthLabel(value: string) {
switch (value) {
case 'ok':
return '健康'
case 'failed':
return '异常'
default:
return '未知'
}
}
function gpuTypeLabel(value: string) {
if (value === 'integrated') return '核显'
if (value === 'discrete') return '独显'
return value || '-'
}
+79 -12
View File
@@ -9,8 +9,9 @@ import {
ToggleRight, ToggleRight,
Loader2, Loader2,
AlertCircle, AlertCircle,
X,
} from 'lucide-react' } from 'lucide-react'
import { getImages, downloadImage, deleteImage, toggleImage, ImageInfo } from '../services/api' import { getImages, downloadImage, cancelImageDownload, deleteImage, toggleImage, ImageInfo } from '../services/api'
import { useDialog } from '../components/Dialog' import { useDialog } from '../components/Dialog'
export default function ImageManagement() { export default function ImageManagement() {
@@ -34,10 +35,14 @@ export default function ImageManagement() {
useEffect(() => { useEffect(() => {
fetchImages() fetchImages()
const interval = setInterval(fetchImages, 5000)
return () => clearInterval(interval)
}, [fetchImages]) }, [fetchImages])
useEffect(() => {
const hasDownloads = images.some((img) => img.downloading)
const interval = setInterval(fetchImages, hasDownloads ? 1500 : 5000)
return () => clearInterval(interval)
}, [fetchImages, images])
const handleDownload = async (templateId: string) => { const handleDownload = async (templateId: string) => {
setActionLoading(templateId) setActionLoading(templateId)
setError('') setError('')
@@ -51,6 +56,19 @@ export default function ImageManagement() {
} }
} }
const handleCancelDownload = async (templateId: string) => {
setActionLoading(templateId)
setError('')
try {
await cancelImageDownload(templateId)
await fetchImages()
} catch (err: unknown) {
setError(apiErrorMessage(err, '取消失败'))
} finally {
setActionLoading(null)
}
}
const handleDelete = async (templateId: string) => { const handleDelete = async (templateId: string) => {
if (!(await dialog.confirm('删除镜像', '确定要删除该镜像缓存吗?删除后需要重新下载才能使用。'))) return if (!(await dialog.confirm('删除镜像', '确定要删除该镜像缓存吗?删除后需要重新下载才能使用。'))) return
setActionLoading(templateId) setActionLoading(templateId)
@@ -125,6 +143,7 @@ export default function ImageManagement() {
downloadedCount={lxcImages.filter((img) => img.downloaded).length} downloadedCount={lxcImages.filter((img) => img.downloaded).length}
totalCount={lxcImages.length} totalCount={lxcImages.length}
onDownload={handleDownload} onDownload={handleDownload}
onCancelDownload={handleCancelDownload}
onDelete={handleDelete} onDelete={handleDelete}
onToggle={handleToggle} onToggle={handleToggle}
/> />
@@ -136,6 +155,7 @@ export default function ImageManagement() {
downloadedCount={kvmImages.filter((img) => img.downloaded).length} downloadedCount={kvmImages.filter((img) => img.downloaded).length}
totalCount={kvmImages.length} totalCount={kvmImages.length}
onDownload={handleDownload} onDownload={handleDownload}
onCancelDownload={handleCancelDownload}
onDelete={handleDelete} onDelete={handleDelete}
onToggle={handleToggle} onToggle={handleToggle}
/> />
@@ -150,6 +170,7 @@ function ImageTable({
downloadedCount, downloadedCount,
totalCount, totalCount,
onDownload, onDownload,
onCancelDownload,
onDelete, onDelete,
onToggle, onToggle,
}: { }: {
@@ -159,6 +180,7 @@ function ImageTable({
downloadedCount: number downloadedCount: number
totalCount: number totalCount: number
onDownload: (id: string) => void onDownload: (id: string) => void
onCancelDownload: (id: string) => void
onDelete: (id: string) => void onDelete: (id: string) => void
onToggle: (id: string, enabled: boolean) => void onToggle: (id: string, enabled: boolean) => void
}) { }) {
@@ -202,7 +224,7 @@ function ImageTable({
<tr key={img.id} className="hover:bg-gray-50 transition-colors"> <tr key={img.id} className="hover:bg-gray-50 transition-colors">
<td className="px-4 py-3"> <td className="px-4 py-3">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="w-8 h-8 bg-gray-100 rounded-lg flex items-center justify-center flex-shrink-0"> <span className="w-8 h-8 flex items-center justify-center flex-shrink-0">
{getTemplateIcon(img.id)} {getTemplateIcon(img.id)}
</span> </span>
<div> <div>
@@ -242,13 +264,18 @@ function ImageTable({
)} )}
{img.downloading && ( {img.downloading && (
<span className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-amber-50 border border-amber-200 rounded-md text-amber-700 text-xs font-medium"> <button
<Loader2 className="w-3.5 h-3.5 animate-spin" /> onClick={() => onCancelDownload(img.id)}
... disabled={isBusy}
</span> className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md border border-red-200 text-red-600 hover:bg-red-50 transition-colors text-xs font-medium disabled:opacity-50"
title="取消下载并清理临时文件"
>
{isBusy ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <X className="w-3.5 h-3.5" />}
{isBusy ? '取消中...' : '取消'}
</button>
)} )}
{img.downloaded && ( {img.downloaded && !img.downloading && (
<> <>
<button <button
onClick={() => onToggle(img.id, img.enabled)} onClick={() => onToggle(img.id, img.enabled)}
@@ -287,10 +314,33 @@ function ImageTable({
function StatusBadge({ img }: { img: ImageInfo }) { function StatusBadge({ img }: { img: ImageInfo }) {
if (img.downloading) { if (img.downloading) {
const progress = Math.max(0, Math.min(100, img.progress || 0))
const showProgress = img.stage === 'downloading' && progress > 0
return ( return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium bg-amber-50 text-amber-700"> <div className="inline-flex flex-col gap-1">
<span className="w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse" /> <span
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium bg-amber-50 text-amber-700"
title={downloadStatusTitle(img)}
>
<span className="w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse" />
{downloadStatusLabel(img)}
</span>
{showProgress && (
<span className="block h-1 w-24 overflow-hidden rounded-full bg-amber-100">
<span className="block h-full rounded-full bg-amber-500 transition-all" style={{ width: `${progress}%` }} />
</span>
)}
</div>
)
}
if (img.error) {
return (
<span
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium bg-red-50 text-red-600"
title={img.error}
>
<AlertCircle className="w-3 h-3" />
</span> </span>
) )
} }
@@ -318,6 +368,23 @@ function StatusBadge({ img }: { img: ImageInfo }) {
) )
} }
function downloadStatusLabel(img: ImageInfo) {
if (img.stage === 'canceling') return '取消中'
if (img.stage === 'converting') return '转换中'
if (img.stage === 'lxc-create') return '下载中'
if (img.progress > 0) return `下载中 ${Math.min(100, img.progress)}%`
return '下载中'
}
function downloadStatusTitle(img: ImageInfo) {
const parts = [downloadStatusLabel(img)]
if (img.stage) parts.push(`阶段:${img.stage}`)
if (img.downloaded_bytes > 0 || img.total_bytes > 0) {
parts.push(`${formatSize(img.downloaded_bytes)} / ${formatSize(img.total_bytes)}`)
}
return parts.join('')
}
function isWindowsImage(img: ImageInfo) { function isWindowsImage(img: ImageInfo) {
return img.distro === 'windows' || img.id.toLowerCase().includes('windows') return img.distro === 'windows' || img.id.toLowerCase().includes('windows')
} }
+2 -9
View File
@@ -3,13 +3,6 @@ import { Lock, User } from 'lucide-react'
import AppIcon from '../components/AppIcon' import AppIcon from '../components/AppIcon'
import { useAuth } from '../contexts/AuthContext' import { useAuth } from '../contexts/AuthContext'
async function sha256Hash(input: string): Promise<string> {
const msgBuffer = new TextEncoder().encode(input)
const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer)
const hashArray = Array.from(new Uint8Array(hashBuffer))
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('')
}
export default function Login() { export default function Login() {
const { login, accessCodeLogin } = useAuth() const { login, accessCodeLogin } = useAuth()
const [username, setUsername] = useState('') const [username, setUsername] = useState('')
@@ -47,7 +40,7 @@ export default function Login() {
<div className="w-full max-w-md"> <div className="w-full max-w-md">
<div className="bg-white rounded-lg border border-gray-200 shadow-sm p-8"> <div className="bg-white rounded-lg border border-gray-200 shadow-sm p-8">
<div className="flex flex-col items-center mb-8"> <div className="flex flex-col items-center mb-8">
<div className="w-16 h-16 rounded-lg border border-gray-200 bg-gray-50 flex items-center justify-center mb-4"> <div className="w-16 h-16 flex items-center justify-center mb-4">
<AppIcon className="w-10 h-10" /> <AppIcon className="w-10 h-10" />
</div> </div>
<h1 className="text-2xl font-bold text-gray-950">CLICD</h1> <h1 className="text-2xl font-bold text-gray-950">CLICD</h1>
@@ -113,7 +106,7 @@ export default function Login() {
</form> </form>
</div> </div>
<p className="text-center text-xs text-gray-400 mt-6">CLICD v1.0.0</p> <p className="text-center text-xs text-gray-400 mt-6">CLICD v1.1.6</p>
</div> </div>
</div> </div>
) )
+156 -13
View File
@@ -1,6 +1,6 @@
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback } from 'react'
import { RefreshCw } from 'lucide-react' import { FileText, Power, RefreshCw, X } from 'lucide-react'
import { getSecurityAlerts, SecurityAlert } from '../services/api' import { getSecurityAlerts, getSecurityLogs, getSecuritySettings, SecurityAlert, SecurityLog, updateSecuritySettings } from '../services/api'
const typeLabels: Record<string, string> = { const typeLabels: Record<string, string> = {
port_scan: '端口扫描', port_scan: '端口扫描',
@@ -23,12 +23,18 @@ const severityLabels: Record<string, string> = {
export default function Security() { export default function Security() {
const [alerts, setAlerts] = useState<SecurityAlert[]>([]) const [alerts, setAlerts] = useState<SecurityAlert[]>([])
const [autoShutdown, setAutoShutdown] = useState(false)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [savingSettings, setSavingSettings] = useState(false)
const [logAlert, setLogAlert] = useState<SecurityAlert | null>(null)
const [logs, setLogs] = useState<SecurityLog[]>([])
const [logsLoading, setLogsLoading] = useState(false)
const fetchData = useCallback(async () => { const fetchData = useCallback(async () => {
try { try {
const alertRes = await getSecurityAlerts() const [alertRes, settingsRes] = await Promise.all([getSecurityAlerts(), getSecuritySettings()])
if (alertRes.data.data) setAlerts(alertRes.data.data) if (alertRes.data.data) setAlerts(alertRes.data.data)
if (settingsRes.data.data) setAutoShutdown(settingsRes.data.data.auto_shutdown)
} catch (err) { } catch (err) {
console.error(err) console.error(err)
} finally { } finally {
@@ -42,6 +48,36 @@ export default function Security() {
return () => clearInterval(interval) return () => clearInterval(interval)
}, [fetchData]) }, [fetchData])
const handleAutoShutdownChange = async () => {
const next = !autoShutdown
setAutoShutdown(next)
setSavingSettings(true)
try {
const res = await updateSecuritySettings({ auto_shutdown: next })
if (res.data.data) setAutoShutdown(res.data.data.auto_shutdown)
} catch (err) {
console.error(err)
setAutoShutdown(!next)
} finally {
setSavingSettings(false)
}
}
const openLogs = async (alert: SecurityAlert) => {
setLogAlert(alert)
setLogs([])
setLogsLoading(true)
try {
const res = await getSecurityLogs(alert.container_name)
setLogs(filterRelatedLogs(res.data.data || [], alert))
} catch (err) {
console.error(err)
setLogs([])
} finally {
setLogsLoading(false)
}
}
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center py-20"> <div className="flex items-center justify-center py-20">
@@ -52,15 +88,33 @@ export default function Security() {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<h1 className="text-xl font-semibold text-black"></h1> <h1 className="text-xl font-semibold text-black"></h1>
<button <div className="flex flex-wrap items-center gap-2">
onClick={fetchData} <button
className="inline-flex items-center gap-2 px-3 py-2 border border-gray-300 text-gray-700 rounded-md hover:bg-gray-50 text-sm" type="button"
> role="switch"
<RefreshCw className="w-4 h-4" /> aria-checked={autoShutdown}
onClick={handleAutoShutdownChange}
</button> disabled={savingSettings}
title="告警自动关机"
className={`inline-flex h-9 items-center gap-2 rounded-md border px-3 text-sm transition-colors disabled:opacity-60 ${
autoShutdown
? 'border-red-200 bg-red-50 text-red-700 hover:bg-red-100'
: 'border-gray-300 bg-white text-gray-700 hover:bg-gray-50'
}`}
>
<Power className="w-4 h-4" />
<span>{autoShutdown ? '自动关机已开' : '自动关机已关'}</span>
</button>
<button
onClick={fetchData}
className="inline-flex items-center gap-2 px-3 py-2 border border-gray-300 text-gray-700 rounded-md hover:bg-gray-50 text-sm"
>
<RefreshCw className="w-4 h-4" />
</button>
</div>
</div> </div>
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden"> <div className="bg-white border border-gray-200 rounded-lg overflow-hidden">
@@ -93,12 +147,24 @@ export default function Security() {
</td> </td>
<td className="px-4 py-2.5 text-gray-800 whitespace-nowrap">{typeLabels[alert.type] || alert.type}</td> <td className="px-4 py-2.5 text-gray-800 whitespace-nowrap">{typeLabels[alert.type] || alert.type}</td>
<td className="px-4 py-2.5 font-mono text-xs text-gray-700 whitespace-nowrap">{alert.container_name}</td> <td className="px-4 py-2.5 font-mono text-xs text-gray-700 whitespace-nowrap">{alert.container_name}</td>
<td className="px-4 py-2.5 font-mono text-xs text-gray-600 whitespace-nowrap">{alert.source_ip}</td> <td className="px-4 py-2.5 font-mono text-xs text-gray-600 whitespace-nowrap">{alert.source_ip || '-'}</td>
<td className="px-4 py-2.5 font-mono text-xs text-gray-600 whitespace-nowrap"> <td className="px-4 py-2.5 font-mono text-xs text-gray-600 whitespace-nowrap">
{formatTarget(alert)} {formatTarget(alert)}
</td> </td>
<td className="px-4 py-2.5 text-gray-600 whitespace-nowrap">{alert.count}</td> <td className="px-4 py-2.5 text-gray-600 whitespace-nowrap">{alert.count}</td>
<td className="px-4 py-2.5 text-gray-600 min-w-[260px]">{alert.detail}</td> <td className="px-4 py-2.5 text-gray-600 min-w-[300px]">
<div className="flex items-center gap-2">
<span className="min-w-0 flex-1">{alert.detail}</span>
<button
onClick={() => openLogs(alert)}
className="inline-flex shrink-0 items-center gap-1 rounded-md border border-gray-300 px-2 py-1 text-xs text-gray-700 hover:bg-gray-50"
title="查看相关记录"
>
<FileText className="h-3.5 w-3.5" />
</button>
</div>
</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
@@ -106,6 +172,69 @@ export default function Security() {
</div> </div>
)} )}
</div> </div>
{logAlert && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
<div className="w-full max-w-4xl overflow-hidden rounded-lg border border-gray-200 bg-white shadow-xl">
<div className="flex items-start justify-between gap-3 border-b border-gray-200 px-4 py-3">
<div>
<h3 className="text-sm font-semibold text-black"></h3>
<p className="mt-1 text-xs text-gray-500">
{logAlert.container_name} · {typeLabels[logAlert.type] || logAlert.type} · {formatTarget(logAlert)}
</p>
</div>
<button
onClick={() => setLogAlert(null)}
className="rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-black"
title="关闭"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="max-h-[70vh] overflow-auto">
{logAlert.log_line && (
<div className="border-b border-gray-100 bg-gray-50 px-4 py-3">
<div className="mb-1 text-xs font-medium text-gray-600"></div>
<pre className="whitespace-pre-wrap break-all rounded border border-gray-200 bg-white p-3 text-xs text-gray-700">{logAlert.log_line}</pre>
</div>
)}
{logsLoading ? (
<div className="p-8 text-center text-sm text-gray-500">...</div>
) : logs.length === 0 ? (
<div className="p-8 text-center text-sm text-gray-500">
conntrack
</div>
) : (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-100 bg-gray-50 text-left text-xs font-medium text-gray-500">
<th className="px-4 py-2.5"></th>
<th className="px-4 py-2.5"></th>
<th className="px-4 py-2.5"></th>
<th className="px-4 py-2.5"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{logs.map((log, index) => (
<tr key={`${log.src_ip}-${log.src_port}-${log.dst_ip}-${log.dst_port}-${index}`}>
<td className="px-4 py-2.5 font-mono text-xs text-gray-700">{log.protocol || '-'}</td>
<td className="px-4 py-2.5 font-mono text-xs text-gray-700">{log.state || '-'}</td>
<td className="px-4 py-2.5 font-mono text-xs text-gray-600">
{formatEndpoint(log.src_ip, log.src_port)}
</td>
<td className="px-4 py-2.5 font-mono text-xs text-gray-600">
{formatEndpoint(log.dst_ip, log.dst_port)}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
</div>
)}
</div> </div>
) )
} }
@@ -129,3 +258,17 @@ function formatTarget(alert: SecurityAlert): string {
if (!alert.target_ip) return '-' if (!alert.target_ip) return '-'
return alert.target_port > 0 ? `${alert.target_ip}:${alert.target_port}` : alert.target_ip return alert.target_port > 0 ? `${alert.target_ip}:${alert.target_port}` : alert.target_ip
} }
function filterRelatedLogs(logs: SecurityLog[], alert: SecurityAlert): SecurityLog[] {
return logs.filter((log) => {
if (alert.source_ip && log.src_ip !== alert.source_ip) return false
if (alert.target_ip && alert.target_ip !== '*' && log.dst_ip !== alert.target_ip) return false
if (alert.target_port > 0 && log.dst_port !== alert.target_port) return false
return true
})
}
function formatEndpoint(ip: string, port: number): string {
if (!ip) return '-'
return port > 0 ? `${ip}:${port}` : ip
}
+69 -57
View File
@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback } from 'react' import { useCallback, useEffect, useState } from 'react'
import { UserCog, Key, LogIn, Monitor, Clock, Globe } from 'lucide-react' import { Clock, Globe, LogIn, Monitor, UserCog } from 'lucide-react'
import { import {
changePassword, changePassword,
changeUsername, changeUsername,
@@ -20,7 +20,6 @@ export default function Settings() {
const [oldPwd, setOldPwd] = useState('') const [oldPwd, setOldPwd] = useState('')
const [newPwd, setNewPwd] = useState('') const [newPwd, setNewPwd] = useState('')
const [newUsername, setNewUsername] = useState('') const [newUsername, setNewUsername] = useState('')
const [pwdForUser, setPwdForUser] = useState('')
const fetchLogs = useCallback(async () => { const fetchLogs = useCallback(async () => {
try { try {
@@ -33,30 +32,45 @@ export default function Settings() {
} }
}, []) }, [])
useEffect(() => { fetchLogs(); const t = setInterval(fetchLogs, 15000); return () => clearInterval(t) }, [fetchLogs]) useEffect(() => {
fetchLogs()
const timer = setInterval(fetchLogs, 15000)
return () => clearInterval(timer)
}, [fetchLogs])
const handleSaveAccount = async () => { const handleSaveAccount = async () => {
if (!oldPwd) { dialog.alert('提示', '请输入当前密码以确认修改'); return } if (!oldPwd) {
if (!newPwd && !newUsername) { dialog.alert('提示', '至少填写新密码或新用户名中的一项'); return } dialog.alert('提示', '请输入当前密码以确认修改')
if (newPwd && newPwd.length < 6) { dialog.alert('提示', '新密码至少 6 位'); return } return
if (newUsername && newUsername.length < 3) { dialog.alert('提示', '用户名至少 3 位'); return } }
if (!newPwd && !newUsername) {
dialog.alert('提示', '至少填写新密码或新用户名中的一项')
return
}
if (newPwd && newPwd.length < 6) {
dialog.alert('提示', '新密码至少 6 位')
return
}
if (newUsername && newUsername.length < 3) {
dialog.alert('提示', '用户名至少 3 位')
return
}
let results: string[] = [] const results: string[] = []
try { try {
// 先改用户名(用旧密码验证),再改密码,否则改完密码后旧密码就失效了
if (newUsername) { if (newUsername) {
const res = await changeUsername(newUsername, oldPwd) const res = await changeUsername(newUsername, oldPwd)
if (res.data.success) results.push('用户名已修改') results.push(res.data.success ? '用户名已修改' : '用户名修改失败')
else results.push('用户名修改失败')
} }
if (newPwd) { if (newPwd) {
const res = await changePassword(oldPwd, newPwd) const res = await changePassword(oldPwd, newPwd)
if (res.data.success) results.push('密码已修改') results.push(res.data.success ? '密码已修改' : '密码修改失败')
else results.push('密码修改失败')
} }
if (results.length > 0) { if (results.length > 0) {
dialog.alert('完成', results.join('') + '。下次登录生效') dialog.alert('完成', `${results.join('')}。下次登录生效`)
setOldPwd(''); setNewPwd(''); setNewUsername('') setOldPwd('')
setNewPwd('')
setNewUsername('')
} }
} catch (err: unknown) { } catch (err: unknown) {
const e = err as { response?: { data?: { message?: string } } } const e = err as { response?: { data?: { message?: string } } }
@@ -67,48 +81,48 @@ export default function Settings() {
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center py-20"> <div className="flex items-center justify-center py-20">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-black"></div> <div className="h-8 w-8 animate-spin rounded-full border-b-2 border-black"></div>
</div> </div>
) )
} }
const totalPages = Math.ceil(logs.length / pageSize)
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
<h1 className="text-2xl font-bold text-black"></h1> <h1 className="text-2xl font-bold text-black"></h1>
<p className="text-sm text-gray-500 mt-1"></p> <p className="mt-1 text-sm text-gray-500"></p>
</div> </div>
{/* Account Settings */} <div className="rounded-lg border border-gray-200 bg-white p-5">
<div className="bg-white border border-gray-200 rounded-lg p-5"> <h2 className="mb-4 flex items-center gap-2 text-sm font-semibold text-black">
<h2 className="text-sm font-semibold text-black mb-4 flex items-center gap-2"> <UserCog className="h-4 w-4" />
<UserCog className="w-4 h-4" />
</h2> </h2>
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<label className="block text-xs text-gray-500 mb-1"></label> <label className="mb-1 block text-xs text-gray-500"></label>
<input type="text" value={username || ''} disabled className="w-full px-3 py-2 border border-gray-200 rounded-md text-sm text-gray-400 bg-gray-50" /> <input type="text" value={username || ''} disabled className="w-full rounded-md border border-gray-200 bg-gray-50 px-3 py-2 text-sm text-gray-400" />
</div> </div>
<div> <div>
<label className="block text-xs text-gray-500 mb-1"></label> <label className="mb-1 block text-xs text-gray-500"></label>
<input type="text" value={newUsername} onChange={(e) => setNewUsername(e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm text-black bg-white" placeholder="至少 3 位" /> <input type="text" value={newUsername} onChange={(e) => setNewUsername(e.target.value)} className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black" placeholder="至少 3 位" />
</div> </div>
<div className="border-t border-gray-100 pt-3"> <div className="border-t border-gray-100 pt-3">
<label className="block text-xs text-gray-500 mb-1"></label> <label className="mb-1 block text-xs text-gray-500"></label>
<input type="password" value={newPwd} onChange={(e) => setNewPwd(e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm text-black bg-white" placeholder="至少 6 位" /> <input type="password" value={newPwd} onChange={(e) => setNewPwd(e.target.value)} className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black" placeholder="至少 6 位" />
</div> </div>
<div> <div>
<label className="block text-xs text-gray-500 mb-1"></label> <label className="mb-1 block text-xs text-gray-500"></label>
<input type="password" value={oldPwd} onChange={(e) => setOldPwd(e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm text-black bg-white" placeholder="输入当前密码以确认修改" /> <input type="password" value={oldPwd} onChange={(e) => setOldPwd(e.target.value)} className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black" placeholder="输入当前密码以确认修改" />
</div> </div>
<button onClick={handleSaveAccount} className="w-full px-4 py-2 bg-black text-white rounded-md text-sm hover:bg-gray-800"></button> <button onClick={handleSaveAccount} className="w-full rounded-md bg-black px-4 py-2 text-sm text-white hover:bg-gray-800"></button>
</div> </div>
</div> </div>
{/* Login Logs */} <div className="rounded-lg border border-gray-200 bg-white p-5">
<div className="bg-white border border-gray-200 rounded-lg p-5"> <h2 className="mb-4 flex items-center gap-2 text-sm font-semibold text-black">
<h2 className="text-sm font-semibold text-black mb-4 flex items-center gap-2"> <LogIn className="h-4 w-4" />
<LogIn className="w-4 h-4" />
</h2> </h2>
{logs.length === 0 ? ( {logs.length === 0 ? (
<p className="text-sm text-gray-400"></p> <p className="text-sm text-gray-400"></p>
@@ -117,23 +131,23 @@ export default function Settings() {
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-xs"> <table className="w-full text-xs">
<thead> <thead>
<tr className="text-gray-400 border-b border-gray-100"> <tr className="border-b border-gray-100 text-gray-400">
<th className="text-left py-2 font-medium w-40"><span className="inline-flex items-center gap-1"><Clock className="w-3 h-3" /></span></th> <th className="w-40 py-2 text-left font-medium"><span className="inline-flex items-center gap-1"><Clock className="h-3 w-3" /></span></th>
<th className="text-left py-2 font-medium"></th> <th className="py-2 text-left font-medium"></th>
<th className="text-left py-2 font-medium"><span className="inline-flex items-center gap-1"><Globe className="w-3 h-3" />IP</span></th> <th className="py-2 text-left font-medium"><span className="inline-flex items-center gap-1"><Globe className="h-3 w-3" />IP</span></th>
<th className="text-left py-2 font-medium"><span className="inline-flex items-center gap-1"><Monitor className="w-3 h-3" /></span></th> <th className="py-2 text-left font-medium"><span className="inline-flex items-center gap-1"><Monitor className="h-3 w-3" /></span></th>
<th className="text-left py-2 font-medium"></th> <th className="py-2 text-left font-medium"></th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-gray-50"> <tbody className="divide-y divide-gray-50">
{logs.slice((logPage - 1) * pageSize, logPage * pageSize).map((log, i) => ( {logs.slice((logPage - 1) * pageSize, logPage * pageSize).map((log, index) => (
<tr key={i}> <tr key={`${log.time}-${index}`}>
<td className="py-1.5 text-gray-500 font-mono whitespace-nowrap">{log.time}</td> <td className="whitespace-nowrap py-1.5 font-mono text-gray-500">{log.time}</td>
<td className="py-1.5 text-gray-700">{log.username}</td> <td className="py-1.5 text-gray-700">{log.username}</td>
<td className="py-1.5 text-gray-500 font-mono">{log.ip}</td> <td className="py-1.5 font-mono text-gray-500">{log.ip}</td>
<td className="py-1.5 text-gray-500 max-w-[180px] truncate" title={log.user_agent}>{formatUA(log.user_agent)}</td> <td className="max-w-[180px] truncate py-1.5 text-gray-500" title={log.user_agent}>{formatUA(log.user_agent)}</td>
<td className="py-1.5"> <td className="py-1.5">
<span className={`px-1.5 py-0.5 rounded text-xs ${log.success ? 'bg-gray-100 text-gray-700' : 'bg-red-50 text-red-600'}`}> <span className={`rounded px-1.5 py-0.5 text-xs ${log.success ? 'bg-gray-100 text-gray-700' : 'bg-red-50 text-red-600'}`}>
{log.success ? '成功' : '失败'} {log.success ? '成功' : '失败'}
</span> </span>
</td> </td>
@@ -143,23 +157,22 @@ export default function Settings() {
</table> </table>
</div> </div>
{logs.length > pageSize && ( {logs.length > pageSize && (
<div className="flex items-center justify-between mt-3 pt-3 border-t border-gray-100"> <div className="mt-3 flex items-center justify-between border-t border-gray-100 pt-3">
<span className="text-xs text-gray-400"> {logs.length} {logPage}/{Math.ceil(logs.length / pageSize)} </span> <span className="text-xs text-gray-400"> {logs.length} {logPage}/{totalPages} </span>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<button onClick={() => setLogPage(1)} disabled={logPage === 1} className="px-2 py-1 text-xs border border-gray-200 rounded hover:bg-gray-50 disabled:opacity-30"></button> <button onClick={() => setLogPage(1)} disabled={logPage === 1} className="rounded border border-gray-200 px-2 py-1 text-xs hover:bg-gray-50 disabled:opacity-30"></button>
<button onClick={() => setLogPage(p => Math.max(1, p - 1))} disabled={logPage === 1} className="px-2 py-1 text-xs border border-gray-200 rounded hover:bg-gray-50 disabled:opacity-30"></button> <button onClick={() => setLogPage(p => Math.max(1, p - 1))} disabled={logPage === 1} className="rounded border border-gray-200 px-2 py-1 text-xs hover:bg-gray-50 disabled:opacity-30"></button>
{Array.from({length: Math.min(5, Math.ceil(logs.length / pageSize))}, (_, i) => { {Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
const totalPages = Math.ceil(logs.length / pageSize)
let start = Math.max(1, logPage - 2) let start = Math.max(1, logPage - 2)
if (start + 4 > totalPages) start = Math.max(1, totalPages - 4) if (start + 4 > totalPages) start = Math.max(1, totalPages - 4)
const page = start + i const page = start + i
if (page > totalPages) return null if (page > totalPages) return null
return ( return (
<button key={page} onClick={() => setLogPage(page)} className={`w-7 h-7 text-xs rounded ${page === logPage ? 'bg-black text-white' : 'border border-gray-200 hover:bg-gray-50'}`}>{page}</button> <button key={page} onClick={() => setLogPage(page)} className={`h-7 w-7 rounded text-xs ${page === logPage ? 'bg-black text-white' : 'border border-gray-200 hover:bg-gray-50'}`}>{page}</button>
) )
})} })}
<button onClick={() => setLogPage(p => Math.min(Math.ceil(logs.length / pageSize), p + 1))} disabled={logPage >= Math.ceil(logs.length / pageSize)} className="px-2 py-1 text-xs border border-gray-200 rounded hover:bg-gray-50 disabled:opacity-30"></button> <button onClick={() => setLogPage(p => Math.min(totalPages, p + 1))} disabled={logPage >= totalPages} className="rounded border border-gray-200 px-2 py-1 text-xs hover:bg-gray-50 disabled:opacity-30"></button>
<button onClick={() => setLogPage(Math.ceil(logs.length / pageSize))} disabled={logPage >= Math.ceil(logs.length / pageSize)} className="px-2 py-1 text-xs border border-gray-200 rounded hover:bg-gray-50 disabled:opacity-30"></button> <button onClick={() => setLogPage(totalPages)} disabled={logPage >= totalPages} className="rounded border border-gray-200 px-2 py-1 text-xs hover:bg-gray-50 disabled:opacity-30"></button>
</div> </div>
</div> </div>
)} )}
@@ -171,7 +184,6 @@ export default function Settings() {
} }
function formatUA(ua: string): string { function formatUA(ua: string): string {
// Extract browser/OS info from UA string
const parts: string[] = [] const parts: string[] = []
if (ua.includes('Windows NT')) parts.push('Windows') if (ua.includes('Windows NT')) parts.push('Windows')
else if (ua.includes('Mac OS X')) parts.push('macOS') else if (ua.includes('Mac OS X')) parts.push('macOS')
+137 -6
View File
@@ -81,6 +81,9 @@ export interface Container {
snapshot_schedule_last_run: string snapshot_schedule_last_run: string
snapshot_schedule_next_run: string snapshot_schedule_next_run: string
snapshot_schedule_created_by: string snapshot_schedule_created_by: string
policy_blocked?: boolean
policy_blocked_reason?: string
policy_blocked_at?: string
} }
export interface Template { export interface Template {
@@ -91,6 +94,7 @@ export interface Template {
release: string release: string
arch: string arch: string
variant?: string variant?: string
desktop?: string
description: string description: string
} }
@@ -132,6 +136,16 @@ export interface IPv6Status {
prefixes: IPv6PrefixInfo[] prefixes: IPv6PrefixInfo[]
} }
export interface IPv4PrefixInfo {
interface: string
address: string
prefix: string
prefix_len: number
subnet_mask: string
gateway: string
source: string
}
export interface DashboardStats { export interface DashboardStats {
total_containers: number total_containers: number
running: number running: number
@@ -157,6 +171,93 @@ export interface HostInfo {
load: { load1: number; load5: number; load15: number } load: { load1: number; load5: number; load15: number }
} }
export interface HostProbeReport {
generated_at: string
hostname: string
kernel: string
os: string
cpu: {
model: string
cores: number
threads: number
architecture: string
flags: string[]
has_integrated_gpu: boolean
virtualization: boolean
virtualization_key: string
}
memory: {
total_mb: number
used_mb: number
free_mb: number
modules: Array<{
locator: string
size: string
type: string
speed: string
manufacturer: string
part_number: string
serial_number: string
}>
}
disks: Array<{
name: string
path: string
model: string
serial: string
size_bytes: number
type: string
rotational: boolean
mountpoints: string[]
health: string
health_detail: string
smart?: {
available: boolean
life_used_percent?: number
power_on_hours?: number
power_cycle_count?: number
read_data_bytes?: number
written_data_bytes?: number
read_commands?: number
write_commands?: number
wear_leveling_count?: string
erase_count?: string
media_errors?: number
}
}>
network_interfaces: Array<{
name: string
mac: string
state: string
speed_mbps: number
driver: string
model: string
ipv4: Array<{ interface: string; address: string; prefix_len: number; scope: string; gateway?: string }>
ipv6: Array<{ interface: string; address: string; prefix_len: number; scope: string; gateway?: string }>
}>
public_ipv4: string[]
ipv4_addresses: Array<{ interface: string; address: string; prefix_len: number; scope: string; gateway?: string }>
ipv4_prefixes: IPv4PrefixInfo[]
ipv6_addresses: Array<{ interface: string; address: string; prefix_len: number; scope: string; gateway?: string }>
ipv6_prefixes: IPv6PrefixInfo[]
gateways: Array<{ family: string; interface: string; gateway: string }>
gpus: Array<{ name: string; vendor: string; driver: string; type: string }>
runtime: {
lxc_available: boolean
kvm_available: boolean
dev_kvm: boolean
nested_virtualization: boolean
nested_detail: string
support_mode: string
}
system: {
uptime_seconds: number
uptime_text: string
process_count: number
}
environment: Array<{ key: string; label: string; ok: boolean; required: boolean; detail: string }>
}
export interface ContainerUsage { export interface ContainerUsage {
memory_usage_bytes: number memory_usage_bytes: number
memory_total_bytes?: number memory_total_bytes?: number
@@ -241,8 +342,8 @@ export const restartContainer = (id: ContainerIdentifier) =>
export const reinstallContainer = (id: ContainerIdentifier, templateId: string) => export const reinstallContainer = (id: ContainerIdentifier, templateId: string) =>
api.post<APIResponse>(`/containers/${id}/reinstall`, { template_id: templateId }) api.post<APIResponse>(`/containers/${id}/reinstall`, { template_id: templateId })
export const resetSSHPassword = (id: ContainerIdentifier) => export const resetSSHPassword = (id: ContainerIdentifier, password?: string) =>
api.post<APIResponse<{ password: string }>>(`/containers/${id}/reset-password`) api.post<APIResponse<{ password: string }>>(`/containers/${id}/reset-password`, password ? { password } : {})
export const getContainerUsage = (id: ContainerIdentifier) => export const getContainerUsage = (id: ContainerIdentifier) =>
api.get<APIResponse<ContainerUsage>>(`/containers/${id}/usage`) api.get<APIResponse<ContainerUsage>>(`/containers/${id}/usage`)
@@ -354,15 +455,24 @@ export interface ImageInfo {
downloaded: boolean downloaded: boolean
enabled: boolean enabled: boolean
downloading: boolean downloading: boolean
progress: number
downloaded_bytes: number
total_bytes: number
stage?: string
error?: string
size_bytes: number size_bytes: number
manual_path?: string manual_path?: string
desktop?: string
} }
export const getImages = () => export const getImages = () =>
api.get<APIResponse<ImageInfo[]>>('/images') api.get<APIResponse<ImageInfo[]>>('/images')
export const downloadImage = (templateId: string) => export const downloadImage = (templateId: string) =>
api.post<APIResponse>('/images/download', { template_id: templateId }, { timeout: 1800000 }) // 30min timeout api.post<APIResponse>('/images/download', { template_id: templateId })
export const cancelImageDownload = (templateId: string) =>
api.post<APIResponse>('/images/cancel', { template_id: templateId })
export const deleteImage = (templateId: string) => export const deleteImage = (templateId: string) =>
api.delete<APIResponse>('/images/delete', { data: { template_id: templateId } }) api.delete<APIResponse>('/images/delete', { data: { template_id: templateId } })
@@ -380,6 +490,9 @@ export const getDashboard = () =>
export const getHostInfo = () => export const getHostInfo = () =>
api.get<APIResponse<HostInfo>>('/host-info') api.get<APIResponse<HostInfo>>('/host-info')
export const getHostReport = () =>
api.get<APIResponse<HostProbeReport>>('/host-report')
// Snapshots // Snapshots
export interface Snapshot { export interface Snapshot {
id: string id: string
@@ -443,10 +556,9 @@ export const getWebSSHUrl = (containerName: string) => {
return `${protocol}//${window.location.host}/api/ssh?${params.toString()}` return `${protocol}//${window.location.host}/api/ssh?${params.toString()}`
} }
export const getWebVNCUrl = (containerName: string, ticket?: string) => { export const getWebVNCUrl = (containerName: string) => {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const params = new URLSearchParams({ container: containerName }) const params = new URLSearchParams({ container: containerName })
if (ticket) params.set('ticket', ticket)
return `${protocol}//${window.location.host}/api/vnc?${params.toString()}` return `${protocol}//${window.location.host}/api/vnc?${params.toString()}`
} }
@@ -524,6 +636,19 @@ export interface SecuritySummary {
low: number low: number
} }
export interface SecuritySettings {
auto_shutdown: boolean
}
export interface SecurityLog {
src_ip: string
dst_ip: string
src_port: number
dst_port: number
protocol: string
state: string
}
export const getSecurityAlerts = () => export const getSecurityAlerts = () =>
api.get<APIResponse<SecurityAlert[]>>('/security/alerts') api.get<APIResponse<SecurityAlert[]>>('/security/alerts')
@@ -531,11 +656,17 @@ export const checkContainerSecurity = (containerName: string) =>
api.post<APIResponse>('/security/check', { container_name: containerName }) api.post<APIResponse>('/security/check', { container_name: containerName })
export const getSecurityLogs = (containerName: string) => export const getSecurityLogs = (containerName: string) =>
api.get<APIResponse>('/security/logs', { params: { container: containerName } }) api.get<APIResponse<SecurityLog[]>>('/security/logs', { params: { container: containerName } })
export const getSecuritySummary = () => export const getSecuritySummary = () =>
api.get<APIResponse<SecuritySummary>>('/security/summary') api.get<APIResponse<SecuritySummary>>('/security/summary')
export const getSecuritySettings = () =>
api.get<APIResponse<SecuritySettings>>('/security/settings')
export const updateSecuritySettings = (data: SecuritySettings) =>
api.put<APIResponse<SecuritySettings>>('/security/settings', data)
export const createWebSSHTicket = (containerName: string) => export const createWebSSHTicket = (containerName: string) =>
api.post<APIResponse<{ ticket: string }>>('/ssh-ticket', { container_name: containerName }) api.post<APIResponse<{ ticket: string }>>('/ssh-ticket', { container_name: containerName })
+851 -95
View File
File diff suppressed because it is too large Load Diff