From e306d2d06bc2f74ed0a1478e7df8ffacc541cbd1 Mon Sep 17 00:00:00 2001 From: MengMengCode <227010654+MengMengCode@users.noreply.github.com> Date: Fri, 5 Jun 2026 19:23:28 +0800 Subject: [PATCH] first commit --- .github/workflows/build.yml | 66 + .gitignore | 64 + README.md | 129 + backend/go.mod | 12 + backend/go.sum | 10 + backend/internal/api/apikey.go | 263 ++ backend/internal/api/auth.go | 213 ++ backend/internal/api/handlers.go | 440 +++ backend/internal/api/host.go | 376 ++ backend/internal/api/images.go | 320 ++ backend/internal/api/ipv6.go | 21 + backend/internal/api/oversell.go | 224 ++ backend/internal/api/resource_validation.go | 38 + backend/internal/api/security.go | 771 +++++ backend/internal/api/settings.go | 146 + backend/internal/api/ssh.go | 308 ++ backend/internal/api/subuser.go | 422 +++ backend/internal/api/swap.go | 189 ++ backend/internal/api/taskqueue.go | 650 ++++ backend/internal/api/websocket.go | 35 + backend/internal/cli/cli.go | 426 +++ backend/internal/config/config.go | 586 ++++ backend/internal/lxc/expiry.go | 137 + backend/internal/lxc/ipv6.go | 553 +++ backend/internal/lxc/lxc.go | 2374 +++++++++++++ backend/internal/lxc/portmap.go | 196 ++ backend/internal/lxc/templates.go | 74 + backend/internal/server/embed.go | 19 + backend/internal/server/server.go | 138 + backend/internal/server/web/.gitkeep | 1 + backend/main.go | 102 + build.sh | 76 + frontend/index.html | 13 + frontend/package-lock.json | 3023 +++++++++++++++++ frontend/package.json | 30 + frontend/postcss.config.js | 6 + frontend/public/favicon.svg | 1 + frontend/src/App.tsx | 69 + frontend/src/components/AppIcon.tsx | 14 + frontend/src/components/ContainerCard.tsx | 142 + .../src/components/CreateContainerModal.tsx | 342 ++ frontend/src/components/Dialog.tsx | 94 + frontend/src/components/Layout.tsx | 18 + .../src/components/ResourceStatsPanel.tsx | 224 ++ frontend/src/components/RingStats.tsx | 132 + frontend/src/components/Sidebar.tsx | 189 ++ frontend/src/components/WebSSHViewer.tsx | 220 ++ frontend/src/contexts/AuthContext.tsx | 151 + frontend/src/index.css | 32 + frontend/src/main.tsx | 19 + frontend/src/pages/ApiIntegration.tsx | 306 ++ frontend/src/pages/AuditLogs.tsx | 120 + frontend/src/pages/ContainerDetail.tsx | 1482 ++++++++ frontend/src/pages/Containers.tsx | 736 ++++ frontend/src/pages/Dashboard.tsx | 220 ++ frontend/src/pages/ImageManagement.tsx | 283 ++ frontend/src/pages/Login.tsx | 120 + frontend/src/pages/Oversell.tsx | 490 +++ frontend/src/pages/Security.tsx | 131 + frontend/src/pages/Settings.tsx | 188 + frontend/src/services/api.ts | 454 +++ frontend/src/utils/labels.ts | 36 + frontend/tailwind.config.js | 11 + frontend/tsconfig.json | 21 + frontend/vite.config.ts | 18 + install.sh | 111 + 66 files changed, 18825 insertions(+) create mode 100644 .github/workflows/build.yml create mode 100644 .gitignore create mode 100644 README.md create mode 100644 backend/go.mod create mode 100644 backend/go.sum create mode 100644 backend/internal/api/apikey.go create mode 100644 backend/internal/api/auth.go create mode 100644 backend/internal/api/handlers.go create mode 100644 backend/internal/api/host.go create mode 100644 backend/internal/api/images.go create mode 100644 backend/internal/api/ipv6.go create mode 100644 backend/internal/api/oversell.go create mode 100644 backend/internal/api/resource_validation.go create mode 100644 backend/internal/api/security.go create mode 100644 backend/internal/api/settings.go create mode 100644 backend/internal/api/ssh.go create mode 100644 backend/internal/api/subuser.go create mode 100644 backend/internal/api/swap.go create mode 100644 backend/internal/api/taskqueue.go create mode 100644 backend/internal/api/websocket.go create mode 100644 backend/internal/cli/cli.go create mode 100644 backend/internal/config/config.go create mode 100644 backend/internal/lxc/expiry.go create mode 100644 backend/internal/lxc/ipv6.go create mode 100644 backend/internal/lxc/lxc.go create mode 100644 backend/internal/lxc/portmap.go create mode 100644 backend/internal/lxc/templates.go create mode 100644 backend/internal/server/embed.go create mode 100644 backend/internal/server/server.go create mode 100644 backend/internal/server/web/.gitkeep create mode 100644 backend/main.go create mode 100644 build.sh create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/postcss.config.js create mode 100644 frontend/public/favicon.svg create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/components/AppIcon.tsx create mode 100644 frontend/src/components/ContainerCard.tsx create mode 100644 frontend/src/components/CreateContainerModal.tsx create mode 100644 frontend/src/components/Dialog.tsx create mode 100644 frontend/src/components/Layout.tsx create mode 100644 frontend/src/components/ResourceStatsPanel.tsx create mode 100644 frontend/src/components/RingStats.tsx create mode 100644 frontend/src/components/Sidebar.tsx create mode 100644 frontend/src/components/WebSSHViewer.tsx create mode 100644 frontend/src/contexts/AuthContext.tsx create mode 100644 frontend/src/index.css create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/pages/ApiIntegration.tsx create mode 100644 frontend/src/pages/AuditLogs.tsx create mode 100644 frontend/src/pages/ContainerDetail.tsx create mode 100644 frontend/src/pages/Containers.tsx create mode 100644 frontend/src/pages/Dashboard.tsx create mode 100644 frontend/src/pages/ImageManagement.tsx create mode 100644 frontend/src/pages/Login.tsx create mode 100644 frontend/src/pages/Oversell.tsx create mode 100644 frontend/src/pages/Security.tsx create mode 100644 frontend/src/pages/Settings.tsx create mode 100644 frontend/src/services/api.ts create mode 100644 frontend/src/utils/labels.ts create mode 100644 frontend/tailwind.config.js create mode 100644 frontend/tsconfig.json create mode 100644 frontend/vite.config.ts create mode 100644 install.sh diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..9e7dcbb --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,66 @@ +name: Build + +on: + push: + branches: + - main + - master + tags: + - "v*" + pull_request: + workflow_dispatch: + +permissions: + contents: write + +jobs: + linux-amd64: + name: Linux amd64 + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: "1.22.x" + cache-dependency-path: backend/go.sum + + - name: Build + shell: bash + run: bash build.sh + + - name: Package + shell: bash + run: | + mkdir -p dist package/clicd-linux-amd64 + cp build/clicd package/clicd-linux-amd64/clicd + cp build/install.sh package/clicd-linux-amd64/install.sh + chmod +x package/clicd-linux-amd64/clicd package/clicd-linux-amd64/install.sh + tar -C package -czf dist/clicd-linux-amd64.tar.gz clicd-linux-amd64 + cp build/clicd dist/clicd-linux-amd64 + sha256sum dist/* > dist/SHA256SUMS + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: clicd-linux-amd64 + path: dist/* + + - name: Publish GitHub Release + if: startsWith(github.ref, 'refs/tags/v') + env: + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + gh release create "$GITHUB_REF_NAME" dist/* --generate-notes || \ + gh release upload "$GITHUB_REF_NAME" dist/* --clobber diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f631566 --- /dev/null +++ b/.gitignore @@ -0,0 +1,64 @@ +# Dependencies +node_modules/ +frontend/node_modules/ + +# Frontend build output +/frontend/dist/ +/web/ + +# Go embedded frontend build output. +# Keep only the placeholder so `go build` can compile before frontend assets exist. +backend/internal/server/web/* +!backend/internal/server/web/.gitkeep + +# Build artifacts +/build/ +*.exe +*.dll +*.so +*.dylib +*.test +*.out +*.prof + +# Local deploy and debug scripts +deploy.py +check_*.py +reset_pass.py + +# Runtime/config data +.clicd/ +config.json +*.db +*.sqlite +*.sqlite3 + +# Environment and secrets +.env +.env.* +*.pem +*.key +id_rsa* + +# Logs +*.log +logs/ + +# Python cache +__pycache__/ +*.py[cod] + +# Go +backend/vendor/ +backend/tmp/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db diff --git a/README.md b/README.md new file mode 100644 index 0000000..2d7155a --- /dev/null +++ b/README.md @@ -0,0 +1,129 @@ +

+ CLICD +

+ +

CLICD

+ +

+ Go + React + TypeScript + Vite + Tailwind CSS + LXC +

+ +CLICD 是一个面向 LXC 的轻量容器管理面板,提供 Web 控制台、CLI、批量任务、镜像管理、NAT 端口、IPv6 分配、WebSSH、资源限制、流量限制和安全告警能力。它适合用来管理小型 VPS 上的 LXC 容器,也适合需要批量创建和分发子用户管理链接的场景。 + +## 功能介绍 + +1. 支持 Ubuntu、Debian、Alpine、CentOS、Arch Linux、Fedora、Rocky Linux 等系统镜像。镜像可以在镜像管理中按需下载;如果宿主机资源比较小,建议优先选择 Alpine 这类轻量镜像。 +2. 支持 WebSSH 管理,可以在浏览器里一键进入容器终端,不需要手动复制 SSH 密码。 +3. 支持子用户管理链接,管理员可以把指定容器分发给拼车用户,子用户只能管理自己被授权的容器。 +4. 支持设置 NAT4 端口数量、NAT 端口映射和协议限制,并支持分配公网 IPv6。IPv6 分配要求宿主机本身拥有可路由的 IPv6 地址段。 +5. 支持超售容量估算。宿主机控制页提供 KSM 合并、Swap 倾向和 cgroup v2 `memory.reclaim` 一次性回收能力;不会展示 LXC 下无实际通用效果的内存气球回收开关。 +6. 支持 API 接入,可以通过 API 完成容器、任务、镜像、端口、流量、安全告警等功能的自动化控制。 +7. 支持仅使用 CLI 管理。需要关闭 Web 控制台时,可以停止并禁用 systemd 服务,然后使用 `clicd cli --no-web` 进入命令行模式。 +8. 支持设置容器有效期。到期后容器会自动关机,子用户无法继续操作,只有管理员重新设置延期日期后才能恢复使用。 +9. 支持单向和双向网络流量限制。达到限制后容器会自动关机,避免流量超额。 +10. 内置基于 conntrack 的轻量安全告警。系统不会保存完整正常连接日志,但会对端口扫描、横向扫描、爆破倾向、SMTP 滥用、UDP 反射、挖矿端口、代理/VPN/Tor 等可疑行为生成告警并写入审计日志。 + +## 技术栈 + +- Backend: Go, net/http, LXC, cgroup v2, iptables, conntrack +- Frontend: React, TypeScript, Vite, Tailwind CSS, lucide-react, xterm.js +- Runtime: Linux, systemd, LXC +- Build: GitHub Actions, Node.js 20, Go 1.22 + +## 安装 + +推荐使用 GitHub Actions 构建出的 Release 产物。下载 `clicd-linux-amd64.tar.gz` 后在目标服务器上执行: + +```bash +tar -xzf clicd-linux-amd64.tar.gz +cd clicd-linux-amd64 +sudo ./install.sh +``` + +安装完成后访问: + +```text +http://YOUR_SERVER_IP:8999 +``` + +首次启动时会自动初始化管理员账号: + +```text +Username: admin +Password: 随机 16 位密码 +``` + +安装脚本会尝试从 systemd 日志中输出初始账号密码。如果机器上已经存在 `/root/.clicd/config.json`,则不会重新生成密码。 + +查看初始密码日志: + +```bash +journalctl -u clicd --no-pager -n 80 | grep -E "Username:|Password:" +``` + +## GitHub Actions 构建 + +仓库内置 `.github/workflows/build.yml`: + +- 推送到 `main` 或 `master` 时自动构建 Linux amd64 产物。 +- 创建 `v*` 标签时自动发布 GitHub Release。 +- 支持手动 `workflow_dispatch` 构建。 + +发布版本示例: + +```bash +git tag v1.0.0 +git push origin v1.0.0 +``` + +Release 会包含: + +```text +clicd-linux-amd64.tar.gz +clicd-linux-amd64 +SHA256SUMS +``` + +## CLI 模式 + +进入 CLI: + +```bash +clicd cli +``` + +仅使用 CLI,不自动拉起 Web 服务: + +```bash +systemctl stop clicd +systemctl disable clicd +clicd cli --no-web +``` + +重新启用 Web 控制台: + +```bash +systemctl enable --now clicd +``` + +## 常用服务命令 + +```bash +systemctl status clicd +systemctl restart clicd +journalctl -u clicd -f +``` + +## 注意事项 + +- 需要 root 权限安装和运行。 +- 宿主机需要支持 LXC。 +- NAT 和端口映射依赖 iptables。 +- 安全告警依赖 conntrack 或 `/proc/net/nf_conntrack`。 +- IPv6 分配要求宿主机拥有可用公网 IPv6 地址段。 +- 配置文件位于 `/root/.clicd/config.json`,其中包含敏感信息,不要提交到公开仓库。 diff --git a/backend/go.mod b/backend/go.mod new file mode 100644 index 0000000..e9c67ed --- /dev/null +++ b/backend/go.mod @@ -0,0 +1,12 @@ +module clicd + +go 1.22.0 + +require ( + github.com/golang-jwt/jwt/v5 v5.2.1 + github.com/gorilla/websocket v1.5.3 + golang.org/x/crypto v0.28.0 + golang.org/x/term v0.28.0 +) + +require golang.org/x/sys v0.29.0 // indirect diff --git a/backend/go.sum b/backend/go.sum new file mode 100644 index 0000000..14d30e9 --- /dev/null +++ b/backend/go.sum @@ -0,0 +1,10 @@ +github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw= +golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U= +golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= +golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg= +golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek= diff --git a/backend/internal/api/apikey.go b/backend/internal/api/apikey.go new file mode 100644 index 0000000..245a36f --- /dev/null +++ b/backend/internal/api/apikey.go @@ -0,0 +1,263 @@ +package api + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "net" + "net/http" + "strconv" + "strings" + "time" + + "clicd/internal/config" + + "github.com/golang-jwt/jwt/v5" +) + +type ApiKey struct { + ID string `json:"id"` + Name string `json:"name"` + Key string `json:"key,omitempty"` + Prefix string `json:"prefix"` + IPWhitelist string `json:"ip_whitelist"` + CreatedAt string `json:"created_at"` + LastUsed string `json:"last_used"` +} + +// HandleApiKeys handles GET (list) and POST (create) for API keys +func HandleApiKeys(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + listApiKeys(w, r) + case http.MethodPost: + createApiKey(w, r) + default: + jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) + } +} + +// HandleApiKeyDelete handles DELETE for a specific API key +func HandleApiKeyDelete(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) + return + } + keyID := strings.TrimPrefix(r.URL.Path, "/api/api-keys/") + if keyID == "" { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Key ID required"}) + return + } + config.DeleteApiKey(keyID) + jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "API key deleted"}) +} + +func listApiKeys(w http.ResponseWriter, r *http.Request) { + keys := make([]ApiKey, 0) + for _, k := range config.AppConfig.ApiKeys { + keys = append(keys, ApiKey{ + ID: k.ID, + Name: k.Name, + Prefix: k.Prefix, + IPWhitelist: k.IPWhitelist, + CreatedAt: k.CreatedAt, + LastUsed: k.LastUsed, + }) + } + jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: keys}) +} + +func createApiKey(w http.ResponseWriter, r *http.Request) { + var req struct { + Name string `json:"name"` + IPWhitelist string `json:"ip_whitelist"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Name == "" { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Name is required"}) + return + } + + // Generate key: clicd_sk_ + 32 hex chars + rawBytes := make([]byte, 16) + rand.Read(rawBytes) + rawKey := "clicd_sk_" + hex.EncodeToString(rawBytes) + + now := time.Now().Format("2006-01-02 15:04:05") + key := config.ApiKeyConfig{ + ID: generateShortID(), + Name: req.Name, + KeyHash: hashKey(rawKey), + Prefix: rawKey[:13] + "...", + IPWhitelist: strings.TrimSpace(req.IPWhitelist), + CreatedAt: now, + } + config.AppConfig.ApiKeys = append(config.AppConfig.ApiKeys, key) + config.SaveConfig() + + jsonResponse(w, http.StatusCreated, APIResponse{ + Success: true, + Message: "API key created. Save this key now - it won't be shown again.", + Data: ApiKey{ + ID: key.ID, + Name: key.Name, + Key: rawKey, + Prefix: key.Prefix, + IPWhitelist: key.IPWhitelist, + CreatedAt: key.CreatedAt, + }, + }) +} + +func generateShortID() string { + b := make([]byte, 4) + rand.Read(b) + return hex.EncodeToString(b) +} + +// hashKey creates a simple hash for storage (not reversible) +func hashKey(key string) string { + b := make([]byte, 32) + for i := range key { + b[i%32] ^= key[i] + } + return hex.EncodeToString(b) +} + +// validateApiKey checks if the given key is valid and IP is allowed +func validateApiKey(rawKey, clientIP string) bool { + hashed := hashKey(rawKey) + for _, k := range config.AppConfig.ApiKeys { + if k.KeyHash == hashed { + if k.IPWhitelist == "" { + return true + } + return isIPAllowed(clientIP, k.IPWhitelist) + } + } + return false +} + +// isIPAllowed checks if clientIP matches any entry in the whitelist +func isIPAllowed(clientIP, whitelist string) bool { + clientIP = strings.TrimSpace(clientIP) + // Strip port if present + if idx := strings.LastIndex(clientIP, ":"); idx > strings.LastIndex(clientIP, "]") { + clientIP = clientIP[:idx] + } + for _, entry := range strings.Split(whitelist, "\n") { + entry = strings.TrimSpace(entry) + if entry == "" { + continue + } + if strings.Contains(entry, "/") { + // CIDR match + if ipInCIDR(clientIP, entry) { + return true + } + } else if entry == clientIP { + return true + } + } + return false +} + +func ipInCIDR(ipStr, cidr string) bool { + parts := strings.Split(cidr, "/") + if len(parts) != 2 { + return false + } + // Simple prefix match for IPv4 + ip := netParseIP(ipStr) + cidrIP := netParseIP(parts[0]) + if ip == nil || cidrIP == nil { + return false + } + bits, err := strconv.Atoi(parts[1]) + if err != nil || bits < 0 || bits > 32 { + return false + } + mask := uint32(0xFFFFFFFF) << (32 - bits) + ipVal := ip4ToUint32(ip) + cidrVal := ip4ToUint32(cidrIP) + return (ipVal & mask) == (cidrVal & mask) +} + +func netParseIP(s string) net.IP { + s = strings.TrimSpace(s) + if idx := strings.LastIndex(s, ":"); idx > strings.LastIndex(s, "]") { + s = s[:idx] + } + return net.ParseIP(s) +} + +func ip4ToUint32(ip net.IP) uint32 { + ip = ip.To4() + if ip == nil { + return 0 + } + return uint32(ip[0])<<24 | uint32(ip[1])<<16 | uint32(ip[2])<<8 | uint32(ip[3]) +} + +// updateApiKeyLastUsed marks the key as recently used +func updateApiKeyLastUsed(rawKey string) { + hashed := hashKey(rawKey) + now := time.Now().Format("2006-01-02 15:04:05") + for i := range config.AppConfig.ApiKeys { + if config.AppConfig.ApiKeys[i].KeyHash == hashed { + config.AppConfig.ApiKeys[i].LastUsed = now + config.SaveConfig() + return + } + } +} + +// ApiKeyMiddleware authenticates requests via X-API-Key header or ?api_key query param +func ApiKeyMiddleware(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // Check header + apiKey := r.Header.Get("X-API-Key") + if apiKey == "" { + // Check query param + apiKey = r.URL.Query().Get("api_key") + } + if apiKey == "" { + // Check Bearer token (some clients use this) + auth := r.Header.Get("Authorization") + if strings.HasPrefix(auth, "Bearer clicd_sk_") { + apiKey = strings.TrimPrefix(auth, "Bearer ") + } + } + + // Get client IP + clientIP := r.RemoteAddr + if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" { + clientIP = strings.Split(forwarded, ",")[0] + } + if apiKey == "" || !validateApiKey(apiKey, clientIP) { + jsonResponse(w, http.StatusUnauthorized, APIResponse{Success: false, Message: "Invalid API key or IP not in whitelist"}) + return + } + + // Generate a short-lived JWT so downstream admin middleware passes + token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ + "username": config.AppConfig.AdminUser, + "api_key": true, + "exp": time.Now().Add(5 * time.Minute).Unix(), + "iat": time.Now().Unix(), + }) + tokenString, _ := token.SignedString([]byte(config.AppConfig.JWTSecret)) + + // Set cookie for subsequent requests + http.SetCookie(w, &http.Cookie{ + Name: "clicd_token", + Value: tokenString, + Path: "/", + HttpOnly: false, + SameSite: http.SameSiteLaxMode, + MaxAge: 300, + }) + + updateApiKeyLastUsed(apiKey) + next(w, r) + } +} diff --git a/backend/internal/api/auth.go b/backend/internal/api/auth.go new file mode 100644 index 0000000..6ee8088 --- /dev/null +++ b/backend/internal/api/auth.go @@ -0,0 +1,213 @@ +package api + +import ( + "encoding/json" + "net/http" + "strings" + "time" + + "clicd/internal/config" + + "github.com/golang-jwt/jwt/v5" + "golang.org/x/crypto/bcrypt" +) + +type LoginRequest struct { + Username string `json:"username"` + Password string `json:"password"` +} + +type LoginResponse struct { + Token string `json:"token"` + Username string `json:"username"` +} + +type APIResponse struct { + Success bool `json:"success"` + Message string `json:"message,omitempty"` + Data interface{} `json:"data,omitempty"` +} + +func jsonResponse(w http.ResponseWriter, status int, resp APIResponse) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(resp) +} + +func tokenFromRequest(r *http.Request) string { + authHeader := r.Header.Get("Authorization") + if strings.HasPrefix(authHeader, "Bearer ") { + return strings.TrimPrefix(authHeader, "Bearer ") + } + + cookie, err := r.Cookie("clicd_token") + if err == nil { + return cookie.Value + } + + return "" +} + +func isValidToken(tokenString string) bool { + _, ok := claimsFromToken(tokenString) + return ok +} + +func claimsFromToken(tokenString string) (jwt.MapClaims, bool) { + if tokenString == "" { + return nil, false + } + token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, jwt.ErrSignatureInvalid + } + return []byte(config.AppConfig.JWTSecret), nil + }) + if err != nil || !token.Valid { + return nil, false + } + claims, ok := token.Claims.(jwt.MapClaims) + return claims, ok +} + +func claimsFromRequest(r *http.Request) (jwt.MapClaims, bool) { + return claimsFromToken(tokenFromRequest(r)) +} + +func isSubUserRequest(r *http.Request) bool { + claims, ok := claimsFromRequest(r) + if !ok { + return false + } + _, ok = claims["sub_user"] + return ok +} + +func isAuthenticatedRequest(r *http.Request) bool { + return isValidToken(tokenFromRequest(r)) +} + +// HandleLogin processes login requests +func HandleLogin(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) + return + } + + var req LoginRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"}) + return + } + + ip := r.RemoteAddr + if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" { + ip = forwarded + } + ua := r.Header.Get("User-Agent") + + if req.Username != config.AppConfig.AdminUser { + RecordLoginLog(req.Username, ip, ua, false) + jsonResponse(w, http.StatusUnauthorized, APIResponse{Success: false, Message: "Invalid credentials"}) + return + } + + if err := bcrypt.CompareHashAndPassword([]byte(config.AppConfig.AdminPassHash), []byte(req.Password)); err != nil { + RecordLoginLog(req.Username, ip, ua, false) + jsonResponse(w, http.StatusUnauthorized, APIResponse{Success: false, Message: "Invalid credentials"}) + return + } + + RecordLoginLog(req.Username, ip, ua, true) + + // Generate JWT token + token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ + "username": req.Username, + "exp": time.Now().Add(24 * time.Hour).Unix(), + "iat": time.Now().Unix(), + }) + + tokenString, err := token.SignedString([]byte(config.AppConfig.JWTSecret)) + if err != nil { + jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to generate token"}) + return + } + + jsonResponse(w, http.StatusOK, APIResponse{ + Success: true, + Data: LoginResponse{ + Token: tokenString, + Username: req.Username, + }, + }) +} + +// HandleChangePassword processes password change requests +func HandleChangePassword(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) + return + } + + var req struct { + OldPassword string `json:"old_password"` + NewPassword string `json:"new_password"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"}) + return + } + + if len(req.NewPassword) < 8 { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "New password must be at least 8 characters"}) + return + } + + if err := bcrypt.CompareHashAndPassword([]byte(config.AppConfig.AdminPassHash), []byte(req.OldPassword)); err != nil { + jsonResponse(w, http.StatusUnauthorized, APIResponse{Success: false, Message: "Current password is incorrect"}) + return + } + + hash, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost) + if err != nil { + jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to hash password"}) + return + } + + config.AppConfig.AdminPassHash = string(hash) + if err := config.SaveConfig(); err != nil { + jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to save configuration"}) + return + } + + jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Password changed successfully"}) +} + +// HandleCheckAuth checks if the user is authenticated +func HandleCheckAuth(w http.ResponseWriter, r *http.Request) { + jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Authenticated"}) +} + +// AuthMiddleware extracts JWT from cookies or Authorization header +func AuthMiddleware(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + tokenString := tokenFromRequest(r) + if !isValidToken(tokenString) { + jsonResponse(w, http.StatusUnauthorized, APIResponse{Success: false, Message: "Authentication required"}) + return + } + + next(w, r) + } +} + +// AdminMiddleware requires a valid administrator token and rejects sub-user tokens. +func AdminMiddleware(next http.HandlerFunc) http.HandlerFunc { + return AuthMiddleware(func(w http.ResponseWriter, r *http.Request) { + if isSubUserRequest(r) { + jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Administrator permission required"}) + return + } + next(w, r) + }) +} diff --git a/backend/internal/api/handlers.go b/backend/internal/api/handlers.go new file mode 100644 index 0000000..8034fdf --- /dev/null +++ b/backend/internal/api/handlers.go @@ -0,0 +1,440 @@ +package api + +import ( + "encoding/json" + "net/http" + "strconv" + "strings" + "time" + + "clicd/internal/config" + "clicd/internal/lxc" +) + +var lxcManager = lxc.NewManager() + +// HandleContainers handles container list and creation +func HandleContainers(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + listContainers(w, r) + case http.MethodPost: + createContainer(w, r) + default: + jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) + } +} + +// HandleSingleContainer handles individual container operations by ID or name: /api/containers/{id-or-name}/... +func HandleSingleContainer(w http.ResponseWriter, r *http.Request) { + path := strings.TrimPrefix(r.URL.Path, "/api/containers/") + parts := strings.SplitN(path, "/", 2) + c := containerByIdentifier(parts[0]) + if c == nil { + jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"}) + return + } + id := c.ID + action := "" + if len(parts) > 1 { + action = parts[1] + } + + switch { + case action == "start" && r.Method == http.MethodPost: + HandleSingleTaskAction(w, r, id, "start") + case action == "stop" && r.Method == http.MethodPost: + HandleSingleTaskAction(w, r, id, "stop") + case action == "restart" && r.Method == http.MethodPost: + HandleSingleTaskAction(w, r, id, "restart") + case action == "reinstall" && r.Method == http.MethodPost: + HandleSingleTaskAction(w, r, id, "reinstall") + case action == "delete" && r.Method == http.MethodDelete: + HandleSingleTaskAction(w, r, id, "delete") + case action == "reset-password" && r.Method == http.MethodPost: + resetSSHPassword(w, r, id) + case action == "usage" && r.Method == http.MethodGet: + getUsage(w, r, id) + case action == "traffic" && r.Method == http.MethodGet: + getTraffic(w, r, id) + case action == "traffic-reset" && r.Method == http.MethodPost: + resetTraffic(w, r, id) + case action == "traffic-limit" && r.Method == http.MethodPut: + updateTrafficLimit(w, r, id) + case action == "resource-limit" && r.Method == http.MethodPut: + updateResourceLimit(w, r, id) + case action == "random-port" && r.Method == http.MethodGet: + getRandomPort(w, r, id) + case action == "expiry" && r.Method == http.MethodPut: + updateExpiry(w, r, id) + case action == "ipv6" && r.Method == http.MethodPost: + assignIPv6(w, r, id) + case action == "port-mappings" && r.Method == http.MethodPost: + addPortMapping(w, r, id) + case strings.HasPrefix(action, "port-mappings/") && r.Method == http.MethodPut: + updatePortMapping(w, r, id, strings.TrimPrefix(action, "port-mappings/")) + case strings.HasPrefix(action, "port-mappings/") && r.Method == http.MethodDelete: + deletePortMapping(w, r, id, strings.TrimPrefix(action, "port-mappings/")) + case r.Method == http.MethodGet: + getContainer(w, r, id) + default: + jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Action not found"}) + } +} + +func listContainers(w http.ResponseWriter, r *http.Request) { + containers, err := lxcManager.ListContainers() + if err != nil { + containers = config.AppConfig.Containers + } + containers = filterContainersForRequest(r, containers) + jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: containers}) +} + +func createContainer(w http.ResponseWriter, r *http.Request) { + var cfg lxc.ContainerConfig + if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"}) + return + } + if cfg.Name == "" { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Container name is required"}) + return + } + if cfg.TemplateID == "" { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Template is required"}) + return + } + if cfg.VCPU <= 0 { + cfg.VCPU = 1 + } + if cfg.RAMMB < 128 { + cfg.RAMMB = 512 + } + if cfg.DiskGB < 1 { + cfg.DiskGB = 5 + } + if cfg.PortMappingCount < 2 { + cfg.PortMappingCount = 2 + } + if cfg.PortMappingCount > 64 { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Port mapping count cannot exceed 64"}) + return + } + if err := validateContainerResourceRequest(cfg.VCPU, cfg.RAMMB, cfg.DiskGB); err != nil { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()}) + return + } + if cfg.ExpiresAt != "" { + expiresAt, ok := lxc.ParseExpiration(cfg.ExpiresAt) + if !ok { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid expiration date"}) + return + } + if !time.Now().Before(expiresAt) { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Expiration date must be in the future"}) + return + } + } + + if err := lxcManager.CreateContainer(cfg); err != nil { + jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()}) + return + } + jsonResponse(w, http.StatusCreated, APIResponse{Success: true, Message: "Container created successfully"}) +} + +func getContainer(w http.ResponseWriter, r *http.Request, id int) { + c := config.FindContainer(id) + if c == nil { + jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"}) + return + } + jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: c}) +} + +func getUsage(w http.ResponseWriter, r *http.Request, id int) { + usage, err := lxcManager.GetResourceUsage(id) + if err != nil { + jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()}) + return + } + jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: usage}) +} + +func getTraffic(w http.ResponseWriter, r *http.Request, id int) { + info := lxcManager.GetTrafficInfo(id) + if info == nil { + jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"}) + return + } + jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: info}) +} + +func updateExpiry(w http.ResponseWriter, r *http.Request, id int) { + var req struct { + ExpiresAt string `json:"expires_at"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request"}) + return + } + c := config.FindContainer(id) + if c == nil { + jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"}) + return + } + c.ExpiresAt = req.ExpiresAt + config.SaveConfig() + jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Expiry updated"}) +} + +func resetTraffic(w http.ResponseWriter, r *http.Request, id int) { + c := config.FindContainer(id) + if c == nil { + jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"}) + return + } + c.TrafficUsedRX = 0 + c.TrafficUsedTX = 0 + c.TrafficResetDate = time.Now().Format("2006-01") + config.SaveConfig() + jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Traffic reset"}) +} + +func updateTrafficLimit(w http.ResponseWriter, r *http.Request, id int) { + var req struct { + Mode string `json:"traffic_mode"` + MonthlyGB int `json:"monthly_traffic_gb"` + TrafficInGB int `json:"traffic_in_gb"` + TrafficOutGB int `json:"traffic_out_gb"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request"}) + return + } + c := config.FindContainer(id) + if c == nil { + jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"}) + return + } + c.TrafficMode = req.Mode + c.MonthlyTrafficGB = req.MonthlyGB + c.TrafficInGB = req.TrafficInGB + c.TrafficOutGB = req.TrafficOutGB + config.SaveConfig() + jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Traffic limit updated"}) +} + +func updateResourceLimit(w http.ResponseWriter, r *http.Request, id int) { + var req struct { + VCPU float64 `json:"vcpu"` + RAMMB int `json:"ram_mb"` + IOMBps int `json:"io_speed_mbps"` + BWMbps int `json:"network_bw_mbps"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request"}) + return + } + c := config.FindContainer(id) + if c == nil { + jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"}) + return + } + + // Update config + nextVCPU := c.VCPU + nextRAMMB := c.RAMMB + if req.VCPU > 0 { + nextVCPU = req.VCPU + } + if req.RAMMB > 0 { + nextRAMMB = req.RAMMB + } + if err := validateContainerResourceRequest(nextVCPU, nextRAMMB, c.DiskGB); err != nil { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()}) + return + } + + c.VCPU = nextVCPU + c.RAMMB = nextRAMMB + c.IOSpeedMBps = req.IOMBps + c.NetworkBWMbps = req.BWMbps + config.SaveConfig() + + // Re-apply resource limits to running container + if c.Status == "running" { + if err := lxcManager.ApplyContainerLimits(c); err != nil { + jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()}) + return + } + } + + jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Resource limits updated"}) +} + +func getRandomPort(w http.ResponseWriter, r *http.Request, id int) { + c := config.FindContainer(id) + if c == nil { + jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"}) + return + } + // Find a random unused port between 10000-65535 + used := map[int]bool{} + for _, pm := range c.PortMappings { + used[pm.HostPort] = true + } + // Also check all containers + for _, oc := range config.AppConfig.Containers { + if oc.ID == id { + continue + } + for _, pm := range oc.PortMappings { + used[pm.HostPort] = true + } + } + // Try random ports + for tries := 0; tries < 100; tries++ { + port := 10000 + (int(time.Now().UnixNano()) % 55535) + if !used[port] { + jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]int{"port": port}}) + return + } + } + jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]int{"port": 0}}) +} + +// HandleTemplates returns available LXC templates +func HandleTemplates(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) + return + } + templates := lxc.GetTemplates() + jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: templates}) +} + +// HandleDashboard returns dashboard stats +func HandleDashboard(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) + return + } + containers, err := lxcManager.ListContainers() + if err != nil { + containers = config.AppConfig.Containers + } + running := 0 + stopped := 0 + for _, c := range containers { + if c.Status == "running" { + running++ + } else { + stopped++ + } + } + stats := map[string]interface{}{ + "total_containers": len(containers), + "running": running, + "stopped": stopped, + } + jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: stats}) +} + +// HandleHostInfo returns host machine resource info +func HandleHostInfo(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) + return + } + info := getHostInfo() + jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: info}) +} + +func resetSSHPassword(w http.ResponseWriter, r *http.Request, id int) { + c := config.FindContainer(id) + if c != nil && lxc.IsExpired(*c) { + jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "容器已到期,不允许此操作"}) + return + } + newPassword, err := lxcManager.ResetSSHPassword(id) + if err != nil { + jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()}) + return + } + jsonResponse(w, http.StatusOK, APIResponse{ + Success: true, + Message: "SSH password reset successfully", + Data: map[string]string{"password": newPassword}, + }) +} + +func addPortMapping(w http.ResponseWriter, r *http.Request, id int) { + var pm config.PortMapping + if err := json.NewDecoder(r.Body).Decode(&pm); err != nil { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"}) + return + } + mappings, err := lxcManager.AddPortMapping(id, pm) + if err != nil { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()}) + return + } + jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: mappings}) +} + +func updatePortMapping(w http.ResponseWriter, r *http.Request, id int, indexStr string) { + index, err := strconv.Atoi(indexStr) + if err != nil { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid port mapping index"}) + return + } + var pm config.PortMapping + if err := json.NewDecoder(r.Body).Decode(&pm); err != nil { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"}) + return + } + if isSubUserRequest(r) { + c := config.FindContainer(id) + if c == nil { + jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"}) + return + } + if index < 0 || index >= len(c.PortMappings) { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid port mapping index"}) + return + } + if pm.ContainerPort < 1 || pm.ContainerPort > 65535 { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "container port must be 1-65535"}) + return + } + existing := c.PortMappings[index] + pm = config.PortMapping{ + ContainerPort: pm.ContainerPort, + HostPort: existing.HostPort, + Protocol: existing.Protocol, + Description: existing.Description, + } + } + mappings, err := lxcManager.UpdatePortMapping(id, index, pm) + if err != nil { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()}) + return + } + jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: mappings}) +} + +func deletePortMapping(w http.ResponseWriter, r *http.Request, id int, indexStr string) { + index, err := strconv.Atoi(indexStr) + if err != nil { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid port mapping index"}) + return + } + mappings, err := lxcManager.DeletePortMapping(id, index) + if err != nil { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()}) + return + } + jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: mappings}) +} diff --git a/backend/internal/api/host.go b/backend/internal/api/host.go new file mode 100644 index 0000000..6ebc83e --- /dev/null +++ b/backend/internal/api/host.go @@ -0,0 +1,376 @@ +package api + +import ( + "bufio" + "os" + "os/exec" + "runtime" + "strconv" + "strings" + "sync" + "syscall" + "time" + + "clicd/internal/lxc" +) + +type HostInfo struct { + CPU CpuInfo `json:"cpu"` + RAM MemoryInfo `json:"ram"` + Disk DiskInfo `json:"disk"` + Network NetworkInfo `json:"network"` + DiskIO DiskIOInfo `json:"disk_io"` + Load LoadInfo `json:"load"` +} + +type LoadInfo struct { + Load1 float64 `json:"load1"` + Load5 float64 `json:"load5"` + Load15 float64 `json:"load15"` +} + +type CpuInfo struct { + Cores int `json:"cores"` + Usage float64 `json:"usage_pct"` +} + +type MemoryInfo struct { + TotalMB int64 `json:"total_mb"` + UsedMB int64 `json:"used_mb"` + FreeMB int64 `json:"free_mb"` +} + +type DiskInfo struct { + TotalGB float64 `json:"total_gb"` + UsedGB float64 `json:"used_gb"` + FreeGB float64 `json:"free_gb"` +} + +type NetworkInfo struct { + RXBytes uint64 `json:"rx_bytes"` + TXBytes uint64 `json:"tx_bytes"` + RXBps float64 `json:"rx_bps"` + TXBps float64 `json:"tx_bps"` + PublicIPv4 string `json:"public_ipv4"` + PublicIPv4Interface string `json:"public_ipv4_interface"` + PublicIPv6 string `json:"public_ipv6"` + PublicIPv6Interface string `json:"public_ipv6_interface"` + IPv6Prefixes []lxc.IPv6PrefixInfo `json:"ipv6_prefixes"` +} + +type DiskIOInfo struct { + ReadBytes uint64 `json:"read_bytes"` + WriteBytes uint64 `json:"write_bytes"` + ReadBps float64 `json:"read_bps"` + WriteBps float64 `json:"write_bps"` +} + +var hostCPUMu sync.Mutex +var lastHostCPU cpuTimes +var hostIOMu sync.Mutex +var lastHostIO hostIOSample + +type cpuTimes struct { + Total uint64 + Idle uint64 +} + +type hostIOSample struct { + RXBytes uint64 + TXBytes uint64 + ReadBytes uint64 + WriteBytes uint64 + At int64 +} + +func getHostInfo() HostInfo { + info := HostInfo{ + CPU: CpuInfo{Cores: runtime.NumCPU()}, + } + + info.RAM = getMemoryInfo() + info.Disk = getDiskInfo() + info.CPU.Usage = getCPUUsage() + info.Network, info.DiskIO = getHostRates() + info.Load = getLoadInfo() + return info +} + +func getMemoryInfo() MemoryInfo { + f, err := os.Open("/proc/meminfo") + if err != nil { + return MemoryInfo{TotalMB: 0, UsedMB: 0, FreeMB: 0} + } + defer f.Close() + + var total, available, free int64 + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Text() + fields := strings.Fields(line) + if len(fields) < 2 { + continue + } + val, _ := strconv.ParseInt(fields[1], 10, 64) + switch fields[0] { + case "MemTotal:": + total = val / 1024 + case "MemAvailable:": + available = val / 1024 + case "MemFree:": + free = val / 1024 + } + } + + used := total - available + if available == 0 { + used = total - free + } + + return MemoryInfo{ + TotalMB: total, + UsedMB: used, + FreeMB: available, + } +} + +func getDiskInfo() DiskInfo { + var stat syscall.Statfs_t + if err := syscall.Statfs("/", &stat); err != nil { + // Try command-based fallback + cmd := exec.Command("df", "-BG", "/") + output, err := cmd.Output() + if err == nil { + lines := strings.Split(string(output), "\n") + if len(lines) >= 2 { + fields := strings.Fields(lines[1]) + if len(fields) >= 4 { + total, _ := parseSizeGBf(fields[1]) + used, _ := parseSizeGBf(fields[2]) + free, _ := parseSizeGBf(fields[3]) + return DiskInfo{TotalGB: total, UsedGB: used, FreeGB: free} + } + } + } + return DiskInfo{} + } + + total := float64(int64(stat.Blocks)*int64(stat.Bsize)) / (1024 * 1024 * 1024) + free := float64(int64(stat.Bavail)*int64(stat.Bsize)) / (1024 * 1024 * 1024) + used := total - free + + return DiskInfo{ + TotalGB: total, + UsedGB: used, + FreeGB: free, + } +} + +func getCPUUsage() float64 { + current, err := readCPUTimes() + if err != nil { + return 0 + } + + hostCPUMu.Lock() + defer hostCPUMu.Unlock() + + if lastHostCPU.Total == 0 { + lastHostCPU = current + return 0 + } + + totalDelta := current.Total - lastHostCPU.Total + idleDelta := current.Idle - lastHostCPU.Idle + lastHostCPU = current + + if totalDelta == 0 { + return 0 + } + + usage := (1 - float64(idleDelta)/float64(totalDelta)) * 100 + if usage < 0 { + return 0 + } + if usage > 100 { + return 100 + } + return usage +} + +func readCPUTimes() (cpuTimes, error) { + f, err := os.Open("/proc/stat") + if err != nil { + return cpuTimes{}, err + } + defer f.Close() + + scanner := bufio.NewScanner(f) + if !scanner.Scan() { + return cpuTimes{}, scanner.Err() + } + + fields := strings.Fields(scanner.Text()) + if len(fields) < 8 || fields[0] != "cpu" { + return cpuTimes{}, nil + } + + var values []uint64 + for _, field := range fields[1:] { + value, _ := strconv.ParseUint(field, 10, 64) + values = append(values, value) + } + + var total uint64 + for _, value := range values { + total += value + } + + idle := values[3] + if len(values) > 4 { + idle += values[4] + } + + return cpuTimes{Total: total, Idle: idle}, nil +} + +func parseSizeGB(s string) (int64, error) { + s = strings.TrimSuffix(s, "G") + s = strings.TrimSpace(s) + val, err := strconv.ParseInt(s, 10, 64) + return val, err +} + +func parseSizeGBf(s string) (float64, error) { + s = strings.TrimSuffix(s, "G") + s = strings.TrimSpace(s) + val, err := strconv.ParseFloat(s, 64) + return val, err +} + +func getHostRates() (NetworkInfo, DiskIOInfo) { + rx, tx := readHostNetworkBytes() + readBytes, writeBytes := readHostDiskBytes() + now := unixNano() + + network := NetworkInfo{RXBytes: rx, TXBytes: tx} + publicIPv4 := lxc.DetectPublicIPv4() + network.PublicIPv4 = publicIPv4.Address + network.PublicIPv4Interface = publicIPv4.Interface + network.IPv6Prefixes = lxc.DetectPublicIPv6Prefixes() + if len(network.IPv6Prefixes) > 0 { + network.PublicIPv6 = network.IPv6Prefixes[0].Address + network.PublicIPv6Interface = network.IPv6Prefixes[0].Interface + } + diskIO := DiskIOInfo{ReadBytes: readBytes, WriteBytes: writeBytes} + + hostIOMu.Lock() + defer hostIOMu.Unlock() + + if lastHostIO.At == 0 { + lastHostIO = hostIOSample{RXBytes: rx, TXBytes: tx, ReadBytes: readBytes, WriteBytes: writeBytes, At: now} + return network, diskIO + } + + elapsed := float64(now-lastHostIO.At) / 1_000_000_000 + if elapsed > 0 { + if rx >= lastHostIO.RXBytes { + network.RXBps = float64(rx-lastHostIO.RXBytes) / elapsed + } + if tx >= lastHostIO.TXBytes { + network.TXBps = float64(tx-lastHostIO.TXBytes) / elapsed + } + if readBytes >= lastHostIO.ReadBytes { + diskIO.ReadBps = float64(readBytes-lastHostIO.ReadBytes) / elapsed + } + if writeBytes >= lastHostIO.WriteBytes { + diskIO.WriteBps = float64(writeBytes-lastHostIO.WriteBytes) / elapsed + } + } + + lastHostIO = hostIOSample{RXBytes: rx, TXBytes: tx, ReadBytes: readBytes, WriteBytes: writeBytes, At: now} + return network, diskIO +} + +func readHostNetworkBytes() (uint64, uint64) { + entries, err := os.ReadDir("/sys/class/net") + if err != nil { + return 0, 0 + } + + var rx, tx uint64 + for _, entry := range entries { + name := entry.Name() + if name == "lo" { + continue + } + rx += readUintFile("/sys/class/net/" + name + "/statistics/rx_bytes") + tx += readUintFile("/sys/class/net/" + name + "/statistics/tx_bytes") + } + return rx, tx +} + +func readHostDiskBytes() (uint64, uint64) { + f, err := os.Open("/proc/diskstats") + if err != nil { + return 0, 0 + } + defer f.Close() + + var readSectors, writeSectors uint64 + scanner := bufio.NewScanner(f) + for scanner.Scan() { + fields := strings.Fields(scanner.Text()) + if len(fields) < 14 { + continue + } + device := fields[2] + if strings.HasPrefix(device, "loop") || + strings.HasPrefix(device, "ram") || + strings.HasPrefix(device, "fd") || + strings.HasPrefix(device, "sr") { + continue + } + read, _ := strconv.ParseUint(fields[5], 10, 64) + write, _ := strconv.ParseUint(fields[9], 10, 64) + readSectors += read + writeSectors += write + } + return readSectors * 512, writeSectors * 512 +} + +func readUintFile(path string) uint64 { + data, err := os.ReadFile(path) + if err != nil { + return 0 + } + value, _ := strconv.ParseUint(strings.TrimSpace(string(data)), 10, 64) + return value +} + +func unixNano() int64 { + return time.Now().UnixNano() +} + +func getLoadInfo() LoadInfo { + f, err := os.Open("/proc/loadavg") + if err != nil { + return LoadInfo{} + } + defer f.Close() + + scanner := bufio.NewScanner(f) + if !scanner.Scan() { + return LoadInfo{} + } + + fields := strings.Fields(scanner.Text()) + if len(fields) < 3 { + return LoadInfo{} + } + + load1, _ := strconv.ParseFloat(fields[0], 64) + load5, _ := strconv.ParseFloat(fields[1], 64) + load15, _ := strconv.ParseFloat(fields[2], 64) + return LoadInfo{Load1: load1, Load5: load5, Load15: load15} +} diff --git a/backend/internal/api/images.go b/backend/internal/api/images.go new file mode 100644 index 0000000..42c613c --- /dev/null +++ b/backend/internal/api/images.go @@ -0,0 +1,320 @@ +package api + +import ( + "encoding/json" + "fmt" + "net/http" + "os" + "os/exec" + "path/filepath" + "sync" + + "clicd/internal/config" + "clicd/internal/lxc" +) + +// ImageInfo represents a template image with its download/enable status. +type ImageInfo struct { + ID string `json:"id"` + Name string `json:"name"` + Distro string `json:"distro"` + Release string `json:"release"` + Arch string `json:"arch"` + Description string `json:"description"` + Downloaded bool `json:"downloaded"` + Enabled bool `json:"enabled"` + Downloading bool `json:"downloading"` + SizeBytes int64 `json:"size_bytes"` +} + +var imageDownloadsMu sync.Mutex +var imageDownloads = map[string]bool{} + +// isImageDownloaded checks if the LXC download cache exists for a template. +func isImageDownloaded(distro, release, arch string) bool { + downloaded, _ := imageDownloadedInfo(distro, release, arch) + return downloaded +} + +// imageDownloadedInfo returns whether the image is downloaded and its total size in bytes. +func imageDownloadedInfo(distro, release, arch string) (bool, int64) { + cachePath := filepath.Join("/var/cache/lxc/download", distro, release, arch) + info, err := os.Stat(cachePath) + if err != nil || !info.IsDir() { + return false, 0 + } + // Check directly for rootfs.tar.xz (some LXC versions store it here) + if fi, err := os.Stat(filepath.Join(cachePath, "rootfs.tar.xz")); err == nil { + return true, fi.Size() + } + if fi, err := os.Stat(filepath.Join(cachePath, "meta.tar.xz")); err == nil { + return true, fi.Size() + } + // Check one level deeper (LXC uses variant subdirectories like "default") + entries, err := os.ReadDir(cachePath) + if err != nil { + return false, 0 + } + for _, entry := range entries { + if !entry.IsDir() { + continue + } + subPath := filepath.Join(cachePath, entry.Name()) + if fi, err := os.Stat(filepath.Join(subPath, "rootfs.tar.xz")); err == nil { + return true, fi.Size() + } + if fi, err := os.Stat(filepath.Join(subPath, "meta.tar.xz")); err == nil { + return true, fi.Size() + } + } + return false, 0 +} + +// getEnabledImageSet returns the set of enabled image IDs. +// If none have been explicitly set, all templates are enabled by default. +func getEnabledImageSet() map[string]bool { + set := make(map[string]bool) + if len(config.AppConfig.EnabledImages) == 0 { + for _, t := range lxc.GetTemplates() { + set[t.ID] = true + } + } else { + for _, id := range config.AppConfig.EnabledImages { + set[id] = true + } + } + return set +} + +// HandleImages returns the list of templates with download/enable status. +func HandleImages(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) + return + } + + templates := lxc.GetTemplates() + enabledSet := getEnabledImageSet() + + images := make([]ImageInfo, 0, len(templates)) + for _, t := range templates { + _, downloading := imageDownloads[t.ID] + downloaded, size := imageDownloadedInfo(t.Distro, t.Release, t.Arch) + images = append(images, ImageInfo{ + ID: t.ID, + Name: t.Name, + Distro: t.Distro, + Release: t.Release, + Arch: t.Arch, + Description: t.Description, + Downloaded: downloaded, + Enabled: enabledSet[t.ID], + Downloading: downloading, + SizeBytes: size, + }) + } + + jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: images}) +} + +// HandleImageDownload downloads a template image from the LXC image server. +func HandleImageDownload(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) + return + } + + 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 + } + + tmpl := lxc.FindTemplate(req.TemplateID) + if tmpl == nil { + jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Template not found"}) + return + } + + // Already downloaded? Just enable if needed. + if isImageDownloaded(tmpl.Distro, tmpl.Release, tmpl.Arch) { + ensureImageEnabled(tmpl.ID) + jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Already downloaded"}) + return + } + + // Already downloading? + imageDownloadsMu.Lock() + if imageDownloads[req.TemplateID] { + imageDownloadsMu.Unlock() + jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: "Already downloading"}) + return + } + imageDownloads[req.TemplateID] = true + imageDownloadsMu.Unlock() + + defer func() { + imageDownloadsMu.Lock() + delete(imageDownloads, req.TemplateID) + imageDownloadsMu.Unlock() + }() + + // Auto-enable on download + ensureImageEnabled(tmpl.ID) + + // Download via lxc-create with a temp container, then destroy it. + tmpName := fmt.Sprintf("clicd-img-dl-%s", tmpl.ID) + args := []string{"-n", tmpName, "-t", "download", "--", + "-d", tmpl.Distro, "-r", tmpl.Release, "-a", tmpl.Arch} + if tmpl.Variant != "" { + args = append(args, "--variant", tmpl.Variant) + } + cmd := exec.Command("lxc-create", args...) + output, err := cmd.CombinedOutput() + + // Clean up the temp container unconditionally. + exec.Command("lxc-destroy", "-n", tmpName, "-f").Run() + os.RemoveAll(filepath.Join("/var/lib/lxc", tmpName)) + + if err != nil { + jsonResponse(w, http.StatusInternalServerError, APIResponse{ + Success: false, + Message: fmt.Sprintf("Download failed: %v, output: %s", err, string(output)), + }) + return + } + + jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Downloaded successfully"}) +} + +// HandleImageDelete deletes a cached template image from disk. +func HandleImageDelete(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) + 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 + } + + tmpl := lxc.FindTemplate(req.TemplateID) + if tmpl == nil { + jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Template not found"}) + return + } + + // Remove cache directory + cachePath := filepath.Join("/var/cache/lxc/download", tmpl.Distro, tmpl.Release, tmpl.Arch) + if err := os.RemoveAll(cachePath); err != nil { + jsonResponse(w, http.StatusInternalServerError, APIResponse{ + Success: false, + Message: fmt.Sprintf("Failed to delete image cache: %v", err), + }) + return + } + + // Remove from enabled list + removeImageEnabled(tmpl.ID) + + jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Deleted"}) +} + +// HandleImageToggle enables or disables a template image. +func HandleImageToggle(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) + return + } + + var req struct { + TemplateID string `json:"template_id"` + Enabled bool `json:"enabled"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.TemplateID == "" { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "template_id required"}) + return + } + + if req.Enabled { + ensureImageEnabled(req.TemplateID) + } else { + removeImageEnabled(req.TemplateID) + } + + jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "OK"}) +} + +// HandleEnabledImages returns only the enabled AND downloaded templates. +// Used by container create / reinstall to filter available templates. +func HandleEnabledImages(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) + return + } + + templates := lxc.GetTemplates() + enabledSet := getEnabledImageSet() + + result := make([]lxc.Template, 0) + for _, t := range templates { + if enabledSet[t.ID] && isImageDownloaded(t.Distro, t.Release, t.Arch) { + result = append(result, t) + } + } + + jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: result}) +} + +func ensureImageEnabled(id string) { + // If the enabled list is empty, all templates are currently enabled by default. + // We must populate the list with all template IDs first so that explicit toggles stick. + if len(config.AppConfig.EnabledImages) == 0 { + for _, t := range lxc.GetTemplates() { + config.AppConfig.EnabledImages = append(config.AppConfig.EnabledImages, t.ID) + } + config.SaveConfig() + return // Already contains all IDs including this one + } + found := false + for _, eid := range config.AppConfig.EnabledImages { + if eid == id { + found = true + break + } + } + if !found { + config.AppConfig.EnabledImages = append(config.AppConfig.EnabledImages, id) + config.SaveConfig() + } +} + +func removeImageEnabled(id string) { + // If the enabled list is empty, populate it first with all templates, + // then remove the one being disabled. + if len(config.AppConfig.EnabledImages) == 0 { + for _, t := range lxc.GetTemplates() { + if t.ID != id { + config.AppConfig.EnabledImages = append(config.AppConfig.EnabledImages, t.ID) + } + } + config.SaveConfig() + return + } + filtered := make([]string, 0, len(config.AppConfig.EnabledImages)) + for _, eid := range config.AppConfig.EnabledImages { + if eid != id { + filtered = append(filtered, eid) + } + } + if len(filtered) != len(config.AppConfig.EnabledImages) { + config.AppConfig.EnabledImages = filtered + config.SaveConfig() + } +} diff --git a/backend/internal/api/ipv6.go b/backend/internal/api/ipv6.go new file mode 100644 index 0000000..594d408 --- /dev/null +++ b/backend/internal/api/ipv6.go @@ -0,0 +1,21 @@ +package api + +import "net/http" + +func HandleIPv6Status(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) + return + } + status := lxcManager.DetectIPv6Status() + jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: status}) +} + +func assignIPv6(w http.ResponseWriter, r *http.Request, id int) { + c, err := lxcManager.AssignIPv6(id) + if err != nil { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()}) + return + } + jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "IPv6 assigned", Data: c}) +} diff --git a/backend/internal/api/oversell.go b/backend/internal/api/oversell.go new file mode 100644 index 0000000..2830cf9 --- /dev/null +++ b/backend/internal/api/oversell.go @@ -0,0 +1,224 @@ +package api + +import ( + "encoding/json" + "fmt" + "net/http" + "os" + "os/exec" + "strconv" + "strings" + + "clicd/internal/config" +) + +// HandleOversell handles GET/POST for oversell config +func HandleOversell(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + getOversell(w, r) + case http.MethodPost: + updateOversell(w, r) + default: + jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) + } +} + +func getOversell(w http.ResponseWriter, r *http.Request) { + jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: config.AppConfig.Oversell}) +} + +func updateOversell(w http.ResponseWriter, r *http.Request) { + var cfg config.OversellConfig + if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"}) + return + } + + // Apply KSM + if cfg.KSMEnabled { + exec.Command("sh", "-c", "echo 1 > /sys/kernel/mm/ksm/run 2>/dev/null").Run() + exec.Command("sh", "-c", "echo 1000 > /sys/kernel/mm/ksm/sleep_millisecs 2>/dev/null").Run() + } else { + exec.Command("sh", "-c", "echo 0 > /sys/kernel/mm/ksm/run 2>/dev/null").Run() + } + + // Apply swappiness + if cfg.Swappiness >= 0 && cfg.Swappiness <= 100 { + exec.Command("sh", "-c", fmt.Sprintf("echo %d > /proc/sys/vm/swappiness", cfg.Swappiness)).Run() + } + + // Oversell multipliers are capacity-planning values. They must not increase + // an individual container's CPU or RAM limits. + reapplyContainerLimits() + + config.AppConfig.Oversell = cfg + if err := config.SaveConfig(); err != nil { + jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to save config"}) + return + } + + jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Oversell config updated", Data: cfg}) +} + +// reapplyContainerLimits restores cgroup limits for all running containers from +// their assigned container resources. +func reapplyContainerLimits() { + for _, c := range config.AppConfig.Containers { + if c.Status != "running" { + continue + } + if err := lxcManager.ApplyContainerLimits(&c); err != nil { + fmt.Printf("Warning: failed to reapply resource limits for %s: %v\n", c.LxcName(), err) + } + } +} + +// HandleOversellStatus returns current oversell resource usage +func HandleOversellStatus(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) + return + } + + status := map[string]interface{}{ + "ksm_active": isKSMEnabled(), + "ksm_pages": getKSMPages(), + "ksm_supported": isKSMSupported(), + "swappiness": getSwappiness(), + "reclaim_supported": isMemoryReclaimSupported(), + "allocated_cpu": getAllocatedCPU(), + "allocated_ram_mb": getAllocatedRAM(), + "allocated_disk_gb": getAllocatedDisk(), + } + + jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: status}) +} + +// HandleOversellReclaim triggers one cgroup v2 memory.reclaim pass for running containers. +func HandleOversellReclaim(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) + return + } + + result := reclaimContainerMemory() + jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Memory reclaim triggered", Data: result}) +} + +func reclaimContainerMemory() map[string]interface{} { + attempted := 0 + reclaimed := 0 + unsupported := 0 + errors := make([]string, 0) + + for _, c := range config.AppConfig.Containers { + if c.Status != "running" { + continue + } + attempted++ + reclaimPath := findMemoryReclaimPath(c.LxcName()) + if reclaimPath == "" { + unsupported++ + continue + } + if err := os.WriteFile(reclaimPath, []byte("64M"), 0644); err != nil { + errors = append(errors, fmt.Sprintf("%s: %v", c.Name, err)) + continue + } + reclaimed++ + } + + return map[string]interface{}{ + "attempted": attempted, + "reclaimed": reclaimed, + "unsupported": unsupported, + "errors": errors, + } +} + +func isKSMEnabled() bool { + data, err := os.ReadFile("/sys/kernel/mm/ksm/run") + if err != nil { + return false + } + return strings.TrimSpace(string(data)) == "1" +} + +func isKSMSupported() bool { + if _, err := os.Stat("/sys/kernel/mm/ksm/run"); err != nil { + return false + } + return true +} + +func getKSMPages() int64 { + data, err := os.ReadFile("/sys/kernel/mm/ksm/pages_shared") + if err != nil { + return 0 + } + val, _ := strconv.ParseInt(strings.TrimSpace(string(data)), 10, 64) + return val +} + +func getSwappiness() int { + data, err := os.ReadFile("/proc/sys/vm/swappiness") + if err != nil { + return 60 + } + val, _ := strconv.Atoi(strings.TrimSpace(string(data))) + return val +} + +func isMemoryReclaimSupported() bool { + if _, err := os.Stat("/sys/fs/cgroup/memory.reclaim"); err == nil { + return true + } + for _, c := range config.AppConfig.Containers { + if c.Status != "running" { + continue + } + if findMemoryReclaimPath(c.LxcName()) != "" { + return true + } + } + return false +} + +func findMemoryReclaimPath(lxcName string) string { + candidates := []string{ + fmt.Sprintf("/sys/fs/cgroup/lxc/%s/memory.reclaim", lxcName), + fmt.Sprintf("/sys/fs/cgroup/lxc.payload.%s/memory.reclaim", lxcName), + fmt.Sprintf("/sys/fs/cgroup/system.slice/lxc@%s.service/memory.reclaim", lxcName), + } + for _, path := range candidates { + if _, err := os.Stat(path); err == nil { + return path + } + } + return "" +} + +func getAllocatedCPU() float64 { + total := 0.0 + for _, c := range config.AppConfig.Containers { + total += c.VCPU + } + return total +} + +func getAllocatedRAM() int64 { + total := int64(0) + for _, c := range config.AppConfig.Containers { + total += int64(c.RAMMB) + } + return total +} + +func getAllocatedDisk() int64 { + total := int64(0) + for _, c := range config.AppConfig.Containers { + total += int64(c.DiskGB) + } + return total +} diff --git a/backend/internal/api/resource_validation.go b/backend/internal/api/resource_validation.go new file mode 100644 index 0000000..0666ea2 --- /dev/null +++ b/backend/internal/api/resource_validation.go @@ -0,0 +1,38 @@ +package api + +import ( + "fmt" + "math" +) + +const minVCPU = 0.25 + +func validateContainerResourceRequest(vcpu float64, ramMB int, diskGB int) error { + host := getHostInfo() + + if vcpu <= 0 { + return fmt.Errorf("vCPU must be greater than 0") + } + if vcpu < minVCPU { + return fmt.Errorf("vCPU must be at least %.2f", minVCPU) + } + if math.Abs(vcpu*4-math.Round(vcpu*4)) > 0.000001 { + return fmt.Errorf("vCPU must use 0.25 increments") + } + if host.CPU.Cores > 0 && vcpu > float64(host.CPU.Cores) { + return fmt.Errorf("vCPU cannot exceed host CPU cores (%d)", host.CPU.Cores) + } + if host.RAM.TotalMB > 0 && ramMB > int(host.RAM.TotalMB) { + return fmt.Errorf("memory cannot exceed host memory (%d MB)", host.RAM.TotalMB) + } + if host.Disk.TotalGB > 0 { + maxDiskGB := int(math.Floor(host.Disk.TotalGB)) + if maxDiskGB < 1 { + maxDiskGB = 1 + } + if diskGB > maxDiskGB { + return fmt.Errorf("disk cannot exceed host disk (%d GB)", maxDiskGB) + } + } + return nil +} diff --git a/backend/internal/api/security.go b/backend/internal/api/security.go new file mode 100644 index 0000000..9f64058 --- /dev/null +++ b/backend/internal/api/security.go @@ -0,0 +1,771 @@ +package api + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "os" + "os/exec" + "strconv" + "strings" + "sync" + "time" + + "clicd/internal/config" +) + +// SecurityAlert represents a detected abuse event. +type SecurityAlert struct { + ID string `json:"id"` + ContainerName string `json:"container_name"` + Type string `json:"type"` // port_scan, horizontal_scan, brute_force, ddos, spam, malware, mining, proxy, reflection + Severity string `json:"severity"` // low, medium, high, critical + SourceIP string `json:"source_ip"` + TargetIP string `json:"target_ip"` + TargetPort int `json:"target_port"` + Detail string `json:"detail"` + LogLine string `json:"log_line"` + Timestamp string `json:"timestamp"` + Count int `json:"count"` +} + +// SecurityScanner monitors container network activity for abuse patterns. +type SecurityScanner struct { + mu sync.Mutex + alerts []SecurityAlert + nextID int + scanCount map[string]int + stopChan chan struct{} +} + +type connEntry struct { + dstIP string + dstPort int + proto string + state string + line string +} + +type trafficStats struct { + total int + totalSynSent int + destCounts map[string]int + destPorts map[string]map[int]int + portDestCounts map[int]map[string]int + portTotalCounts map[int]int + udpDestCounts map[int]map[string]int + udpTotalCounts map[int]int + synSentByDst map[string]int +} + +var scanner *SecurityScanner +var scannerStarted bool + +var bruteForcePorts = map[int]string{ + 21: "FTP", + 22: "SSH", + 23: "Telnet", + 135: "MS-RPC", + 139: "NetBIOS", + 445: "SMB", + 3306: "MySQL", + 3389: "RDP", + 5432: "PostgreSQL", + 5900: "VNC", + 5901: "VNC", + 5985: "WinRM", + 5986: "WinRM", + 6379: "Redis", + 9200: "Elasticsearch", + 27017: "MongoDB", +} + +var smtpPorts = map[int]string{ + 25: "SMTP", + 465: "SMTPS", + 587: "SMTP submission", + 2525: "SMTP alternate", +} + +var reflectionPorts = map[int]string{ + 17: "QOTD", + 19: "Chargen", + 53: "DNS", + 69: "TFTP", + 111: "Portmap", + 123: "NTP", + 137: "NetBIOS", + 161: "SNMP", + 389: "CLDAP", + 500: "IKE", + 1900: "SSDP", + 3702: "WS-Discovery", + 4500: "IPsec NAT-T", + 5353: "mDNS", + 11211: "Memcached", +} + +var miningPorts = map[int]string{ + 3333: "Stratum", + 3334: "Stratum", + 3335: "Stratum", + 4444: "Stratum", + 5555: "Stratum", + 7777: "Stratum", + 8888: "Stratum", + 9999: "Stratum", + 14433: "Stratum", + 14444: "Stratum", +} + +var proxyPorts = map[int]string{ + 1080: "SOCKS", + 3128: "HTTP proxy", + 8118: "Privoxy", + 9001: "Tor OR", + 9030: "Tor directory", + 9050: "Tor SOCKS", + 1194: "OpenVPN", + 51820: "WireGuard", +} + +var malwarePorts = map[int]string{ + 1337: "common backdoor", + 31337: "Back Orifice", + 4444: "Metasploit/reverse shell", + 5555: "Android debug/reverse shell", + 6666: "IRC botnet", + 6667: "IRC botnet", + 6697: "IRC over TLS", + 9050: "Tor/C2 proxy", +} + +func InitScanner() { + if scannerStarted { + return + } + scannerStarted = true + scanner = newSecurityScanner() + go scanner.monitorLoop() +} + +func newSecurityScanner() *SecurityScanner { + return &SecurityScanner{ + alerts: make([]SecurityAlert, 0), + scanCount: make(map[string]int), + stopChan: make(chan struct{}), + } +} + +func ensureScanner() *SecurityScanner { + if scanner == nil { + scanner = newSecurityScanner() + } + return scanner +} + +func (ss *SecurityScanner) monitorLoop() { + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ss.stopChan: + return + case <-ticker.C: + ss.checkAllContainers() + } + } +} + +func (ss *SecurityScanner) checkAllContainers() { + for _, c := range config.AppConfig.Containers { + if c.Status != "running" || c.IP == "" { + continue + } + ss.checkContainer(c.Name, c.IP) + } +} + +func (ss *SecurityScanner) checkContainer(name, ip string) { + lines := readConntrackLines(ip) + if len(lines) == 0 { + return + } + + stats := newTrafficStats() + for _, line := range lines { + conn, ok := parseConntrackLine(line, ip) + if !ok || conn.dstIP == "" || conn.dstIP == ip { + continue + } + stats.add(conn) + } + + if stats.total == 0 { + return + } + + ss.detectPortScans(name, ip, stats) + ss.detectBruteForce(name, ip, stats) + ss.detectSpam(name, ip, stats) + ss.detectMassAbuse(name, ip, stats) + ss.detectReflectionAbuse(name, ip, stats) + ss.detectMining(name, ip, stats) + ss.detectProxyAndTor(name, ip, stats) + ss.detectMalware(name, ip, stats) +} + +func newTrafficStats() *trafficStats { + return &trafficStats{ + destCounts: make(map[string]int), + destPorts: make(map[string]map[int]int), + portDestCounts: make(map[int]map[string]int), + portTotalCounts: make(map[int]int), + udpDestCounts: make(map[int]map[string]int), + udpTotalCounts: make(map[int]int), + synSentByDst: make(map[string]int), + } +} + +func (ts *trafficStats) add(conn connEntry) { + ts.total++ + ts.destCounts[conn.dstIP]++ + + if conn.dstPort > 0 { + if ts.destPorts[conn.dstIP] == nil { + ts.destPorts[conn.dstIP] = make(map[int]int) + } + ts.destPorts[conn.dstIP][conn.dstPort]++ + + if ts.portDestCounts[conn.dstPort] == nil { + ts.portDestCounts[conn.dstPort] = make(map[string]int) + } + ts.portDestCounts[conn.dstPort][conn.dstIP]++ + ts.portTotalCounts[conn.dstPort]++ + + if conn.proto == "udp" { + if ts.udpDestCounts[conn.dstPort] == nil { + ts.udpDestCounts[conn.dstPort] = make(map[string]int) + } + ts.udpDestCounts[conn.dstPort][conn.dstIP]++ + ts.udpTotalCounts[conn.dstPort]++ + } + } + + if conn.state == "SYN_SENT" { + ts.totalSynSent++ + ts.synSentByDst[conn.dstIP]++ + } +} + +func (ss *SecurityScanner) detectPortScans(name, ip string, stats *trafficStats) { + for dstIP, portCounts := range stats.destPorts { + uniquePorts := len(portCounts) + switch { + case uniquePorts >= 20: + ss.addAlert(name, "port_scan", "high", ip, dstIP, 0, + fmt.Sprintf("端口扫描: 同一目标 %s 出现 %d 个不同目标端口", dstIP, uniquePorts), + "") + case uniquePorts >= 8: + ss.addAlert(name, "port_scan", "medium", ip, dstIP, 0, + fmt.Sprintf("可疑端口探测: 同一目标 %s 出现 %d 个不同目标端口", dstIP, uniquePorts), + "") + } + } + + for port, targets := range stats.portDestCounts { + uniqueTargets := len(targets) + if service, ok := bruteForcePorts[port]; ok { + if uniqueTargets >= 30 { + ss.addAlert(name, "brute_force", "critical", ip, "*", port, + fmt.Sprintf("横向爆破: 目标服务 %s(%d) 覆盖 %d 个不同 IP", service, port, uniqueTargets), + "") + } else if uniqueTargets >= 10 { + ss.addAlert(name, "brute_force", "high", ip, "*", port, + fmt.Sprintf("疑似横向爆破: 目标服务 %s(%d) 覆盖 %d 个不同 IP", service, port, uniqueTargets), + "") + } + continue + } + + if uniqueTargets >= 40 { + ss.addAlert(name, "horizontal_scan", "high", ip, "*", port, + fmt.Sprintf("横向扫描: 同一端口 %d 覆盖 %d 个不同目标", port, uniqueTargets), + "") + } else if uniqueTargets >= 15 { + ss.addAlert(name, "horizontal_scan", "medium", ip, "*", port, + fmt.Sprintf("可疑横向探测: 同一端口 %d 覆盖 %d 个不同目标", port, uniqueTargets), + "") + } + } +} + +func (ss *SecurityScanner) detectBruteForce(name, ip string, stats *trafficStats) { + for dstIP, portCounts := range stats.destPorts { + for port, count := range portCounts { + service, sensitive := bruteForcePorts[port] + if !sensitive { + continue + } + + if count >= 20 { + ss.addAlert(name, "brute_force", "critical", ip, dstIP, port, + fmt.Sprintf("暴力破解: %s(%d) 当前连接数 %d", service, port, count), + "") + } else if count >= 10 { + ss.addAlert(name, "brute_force", "high", ip, dstIP, port, + fmt.Sprintf("疑似暴力破解: %s(%d) 当前连接数 %d", service, port, count), + "") + } + } + } +} + +func (ss *SecurityScanner) detectSpam(name, ip string, stats *trafficStats) { + total, targets := countPorts(stats.portTotalCounts, stats.portDestCounts, smtpPorts) + if total == 0 { + return + } + + if targets >= 10 || total >= 30 { + ss.addAlert(name, "spam", "critical", ip, "*", 25, + fmt.Sprintf("疑似垃圾邮件: SMTP 相关端口当前连接 %d 条,覆盖 %d 个目标", total, targets), + "") + } else if targets >= 2 || total >= 5 { + ss.addAlert(name, "spam", "high", ip, "*", 25, + fmt.Sprintf("可疑邮件发送: SMTP 相关端口当前连接 %d 条,覆盖 %d 个目标", total, targets), + "") + } +} + +func (ss *SecurityScanner) detectMassAbuse(name, ip string, stats *trafficStats) { + targets := len(stats.destCounts) + switch { + case targets >= 100: + ss.addAlert(name, "ddos", "critical", ip, "*", 0, + fmt.Sprintf("大规模对外连接: 当前覆盖 %d 个不同目标", targets), + "") + case targets >= 35: + ss.addAlert(name, "ddos", "high", ip, "*", 0, + fmt.Sprintf("大量对外连接: 当前覆盖 %d 个不同目标", targets), + "") + } + + switch { + case stats.total >= 500: + ss.addAlert(name, "ddos", "critical", ip, "*", 0, + fmt.Sprintf("异常大量连接: 当前 conntrack 出站记录 %d 条", stats.total), + "") + case stats.total >= 200: + ss.addAlert(name, "ddos", "high", ip, "*", 0, + fmt.Sprintf("高连接数: 当前 conntrack 出站记录 %d 条", stats.total), + "") + } + + if stats.totalSynSent >= 100 { + ss.addAlert(name, "ddos", "critical", ip, "*", 0, + fmt.Sprintf("大量半开连接: 当前 SYN_SENT %d 条", stats.totalSynSent), + "") + } + + for dstIP, count := range stats.synSentByDst { + if count >= 50 { + ss.addAlert(name, "ddos", "critical", ip, dstIP, 0, + fmt.Sprintf("SYN 洪水: 单一目标半开连接 %d 条", count), + "") + } else if count >= 20 { + ss.addAlert(name, "ddos", "high", ip, dstIP, 0, + fmt.Sprintf("可疑 SYN 洪水: 单一目标半开连接 %d 条", count), + "") + } + } +} + +func (ss *SecurityScanner) detectReflectionAbuse(name, ip string, stats *trafficStats) { + for port, service := range reflectionPorts { + total := stats.udpTotalCounts[port] + targets := len(stats.udpDestCounts[port]) + if total == 0 { + continue + } + + if targets >= 30 || total >= 100 { + ss.addAlert(name, "reflection", "critical", ip, "*", port, + fmt.Sprintf("UDP 反射放大: %s(%d) 当前 UDP 连接 %d 条,覆盖 %d 个目标", service, port, total, targets), + "") + } else if targets >= 10 || total >= 30 { + ss.addAlert(name, "reflection", "high", ip, "*", port, + fmt.Sprintf("疑似 UDP 反射放大: %s(%d) 当前 UDP 连接 %d 条,覆盖 %d 个目标", service, port, total, targets), + "") + } + } +} + +func (ss *SecurityScanner) detectMining(name, ip string, stats *trafficStats) { + for port, service := range miningPorts { + total := stats.portTotalCounts[port] + if total == 0 { + continue + } + + severity := "high" + if total >= 5 { + severity = "critical" + } + ss.addAlert(name, "mining", severity, ip, "*", port, + fmt.Sprintf("疑似挖矿连接: %s/%d 当前连接 %d 条", service, port, total), + "") + } +} + +func (ss *SecurityScanner) detectProxyAndTor(name, ip string, stats *trafficStats) { + for port, service := range proxyPorts { + total := stats.portTotalCounts[port] + targets := len(stats.portDestCounts[port]) + if total == 0 { + continue + } + + if port == 1194 || port == 51820 { + if targets < 3 && total < 10 { + continue + } + } + + severity := "high" + if targets >= 10 || total >= 30 { + severity = "critical" + } + ss.addAlert(name, "proxy", severity, ip, "*", port, + fmt.Sprintf("疑似代理/VPN/Tor 滥用: %s(%d) 当前连接 %d 条,覆盖 %d 个目标", service, port, total, targets), + "") + } + + total8080 := stats.portTotalCounts[8080] + targets8080 := len(stats.portDestCounts[8080]) + if targets8080 >= 5 || total8080 >= 20 { + ss.addAlert(name, "proxy", "high", ip, "*", 8080, + fmt.Sprintf("疑似开放代理流量: HTTP 代理常用端口 8080 当前连接 %d 条,覆盖 %d 个目标", total8080, targets8080), + "") + } +} + +func (ss *SecurityScanner) detectMalware(name, ip string, stats *trafficStats) { + for port, label := range malwarePorts { + total := stats.portTotalCounts[port] + if total == 0 { + continue + } + + ss.addAlert(name, "malware", "critical", ip, "*", port, + fmt.Sprintf("疑似恶意软件/C2 连接: %s 端口 %d 当前连接 %d 条", label, port, total), + "") + } +} + +func readConntrackLines(ip string) []string { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, "conntrack", "-L", "-s", ip) + output, err := cmd.Output() + if err == nil && len(output) > 0 { + return splitNonEmptyLines(string(output)) + } + + var lines []string + for _, path := range []string{"/proc/net/nf_conntrack", "/proc/net/ip_conntrack"} { + data, readErr := os.ReadFile(path) + if readErr != nil { + continue + } + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + if strings.Contains(line, "src="+ip+" ") { + lines = append(lines, line) + } + } + } + return lines +} + +func splitNonEmptyLines(raw string) []string { + lines := make([]string, 0) + for _, line := range strings.Split(raw, "\n") { + line = strings.TrimSpace(line) + if line != "" { + lines = append(lines, line) + } + } + return lines +} + +func parseConntrackLine(line, containerIP string) (connEntry, bool) { + srcIP := extractField(line, "src=") + if srcIP != containerIP { + return connEntry{}, false + } + + dstIP := extractField(line, "dst=") + dstPort, _ := strconv.Atoi(extractField(line, "dport=")) + + return connEntry{ + dstIP: dstIP, + dstPort: dstPort, + proto: extractProtocol(line), + state: extractConnState(line), + line: line, + }, true +} + +func extractProtocol(line string) string { + for _, field := range strings.Fields(line) { + switch field { + case "tcp", "udp", "icmp", "icmpv6", "sctp": + return field + } + } + return "" +} + +func extractConnState(line string) string { + for _, field := range strings.Fields(line) { + switch field { + case "SYN_SENT", "SYN_RECV", "ESTABLISHED", "TIME_WAIT", "CLOSE", "CLOSE_WAIT", "FIN_WAIT", "LAST_ACK", "UNREPLIED": + return field + } + } + return "" +} + +func countPorts(totalCounts map[int]int, destCounts map[int]map[string]int, ports map[int]string) (int, int) { + total := 0 + targets := make(map[string]struct{}) + for port := range ports { + total += totalCounts[port] + for dstIP := range destCounts[port] { + targets[dstIP] = struct{}{} + } + } + return total, len(targets) +} + +func (ss *SecurityScanner) addAlert(name, alertType, severity, srcIP, dstIP string, port int, detail, logLine string) { + ss.mu.Lock() + defer ss.mu.Unlock() + + now := time.Now() + cutoff := now.Add(-5 * time.Minute) + + for i := range ss.alerts { + a := &ss.alerts[i] + if a.ContainerName != name || a.Type != alertType || a.TargetIP != dstIP || a.TargetPort != port { + continue + } + t, err := time.Parse("2006-01-02 15:04:05", a.Timestamp) + if err != nil || t.Before(cutoff) { + continue + } + + a.Count++ + a.Detail = detail + a.LogLine = logLine + a.Timestamp = now.Format("2006-01-02 15:04:05") + if severityRank(severity) > severityRank(a.Severity) { + a.Severity = severity + } + return + } + + ss.nextID++ + alert := SecurityAlert{ + ID: fmt.Sprintf("alert-%d", ss.nextID), + ContainerName: name, + Type: alertType, + Severity: severity, + SourceIP: srcIP, + TargetIP: dstIP, + TargetPort: port, + Detail: detail, + LogLine: logLine, + Timestamp: now.Format("2006-01-02 15:04:05"), + Count: 1, + } + + ss.alerts = append(ss.alerts, alert) + config.AddAuditLog("security_"+alertType, name, fmt.Sprintf("[%s] %s", severity, detail), "system") + + if len(ss.alerts) > 200 { + ss.alerts = ss.alerts[len(ss.alerts)-200:] + } +} + +func severityRank(severity string) int { + switch severity { + case "critical": + return 4 + case "high": + return 3 + case "medium": + return 2 + case "low": + return 1 + default: + return 0 + } +} + +// HandleSecurityAlerts returns all security alerts. +func HandleSecurityAlerts(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) + return + } + + ss := ensureScanner() + 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}) +} + +// HandleSecurityCheck triggers immediate security check for a container. +func HandleSecurityCheck(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) + return + } + + var req struct { + ContainerName string `json:"container_name"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"}) + return + } + + c := config.FindContainerByName(req.ContainerName) + if c == nil || c.IP == "" { + jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found or not running"}) + return + } + + ensureScanner().checkContainer(c.Name, c.IP) + jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Security check completed"}) +} + +// HandleSecurityLogs returns connection logs for a container. +func HandleSecurityLogs(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) + return + } + + containerName := r.URL.Query().Get("container") + if containerName == "" { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Container name required"}) + return + } + + c := config.FindContainerByName(containerName) + if c == nil || c.IP == "" { + jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: []map[string]interface{}{}}) + return + } + + jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: getConnectionLogs(c.IP)}) +} + +func getConnectionLogs(ip string) []map[string]interface{} { + logs := make([]map[string]interface{}, 0) + + for _, line := range readConntrackLines(ip) { + srcIP := extractField(line, "src=") + dstIP := extractField(line, "dst=") + srcPort := extractField(line, "sport=") + dstPort := extractField(line, "dport=") + + sPort, _ := strconv.Atoi(srcPort) + dPort, _ := strconv.Atoi(dstPort) + + logs = append(logs, map[string]interface{}{ + "src_ip": srcIP, + "dst_ip": dstIP, + "src_port": sPort, + "dst_port": dPort, + "protocol": extractProtocol(line), + "state": extractConnState(line), + }) + + if len(logs) >= 100 { + break + } + } + + return logs +} + +func extractField(line, prefix string) string { + idx := strings.Index(line, prefix) + if idx == -1 { + return "" + } + start := idx + len(prefix) + end := start + for end < len(line) && line[end] != ' ' && line[end] != '\t' { + end++ + } + return line[start:end] +} + +// HandleContainerSecuritySummary returns security status for dashboard. +func HandleContainerSecuritySummary(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) + return + } + + ss := ensureScanner() + ss.mu.Lock() + critical := 0 + high := 0 + medium := 0 + low := 0 + for _, a := range ss.alerts { + switch a.Severity { + case "critical": + critical++ + case "high": + high++ + case "medium": + medium++ + case "low": + low++ + } + } + total := len(ss.alerts) + ss.mu.Unlock() + + summary := map[string]interface{}{ + "total_alerts": total, + "critical": critical, + "high": high, + "medium": medium, + "low": low, + } + + jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: summary}) +} diff --git a/backend/internal/api/settings.go b/backend/internal/api/settings.go new file mode 100644 index 0000000..cc24fd3 --- /dev/null +++ b/backend/internal/api/settings.go @@ -0,0 +1,146 @@ +package api + +import ( + "encoding/json" + "net/http" + "time" + + "clicd/internal/config" + + "golang.org/x/crypto/bcrypt" +) + +type LoginLog struct { + Time string `json:"time"` + Username string `json:"username"` + IP string `json:"ip"` + UserAgent string `json:"user_agent"` + Success bool `json:"success"` +} + +var loginLogs = make([]LoginLog, 0) + +// RecordLoginLog adds a login attempt to the log (persisted to config) +func RecordLoginLog(username, ip, userAgent string, success bool) { + config.AddLoginLog(username, ip, userAgent, success) + + log := LoginLog{ + Time: time.Now().UTC().Format("2006-01-02 15:04:05 UTC"), + Username: username, + IP: ip, + UserAgent: userAgent, + Success: success, + } + loginLogs = append(loginLogs, log) + if len(loginLogs) > 200 { + loginLogs = loginLogs[len(loginLogs)-200:] + } +} + +// RestoreLoginLogs restores login logs from config +func RestoreLoginLogs() { + for _, l := range config.AppConfig.LoginLogs { + loginLogs = append(loginLogs, LoginLog{ + Time: l.Time, + Username: l.Username, + IP: l.IP, + UserAgent: l.UserAgent, + Success: l.Success, + }) + } +} + +// HandleLoginLogs returns login history +func HandleLoginLogs(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) + return + } + + // Return in reverse (newest first) + reversed := make([]LoginLog, len(loginLogs)) + for i, l := range loginLogs { + reversed[len(loginLogs)-1-i] = l + } + if reversed == nil { + reversed = []LoginLog{} + } + + jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: reversed}) +} + +// HandleAdminPasswordChange changes admin password +func HandleAdminPasswordChange(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) + return + } + + var req struct { + OldPassword string `json:"old_password"` + NewPassword string `json:"new_password"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"}) + return + } + + if len(req.NewPassword) < 6 { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "新密码至少 6 位"}) + return + } + + if err := bcrypt.CompareHashAndPassword([]byte(config.AppConfig.AdminPassHash), []byte(req.OldPassword)); err != nil { + jsonResponse(w, http.StatusUnauthorized, APIResponse{Success: false, Message: "当前密码不正确"}) + return + } + + hash, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost) + if err != nil { + jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "密码加密失败"}) + return + } + + config.AppConfig.AdminPassHash = string(hash) + if err := config.SaveConfig(); err != nil { + jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "保存配置失败"}) + return + } + + jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "密码修改成功"}) +} + +// HandleAdminUsernameChange changes admin username +func HandleAdminUsernameChange(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) + return + } + + var req struct { + NewUsername string `json:"new_username"` + Password string `json:"password"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"}) + return + } + + if len(req.NewUsername) < 3 { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "用户名至少 3 位"}) + return + } + + if err := bcrypt.CompareHashAndPassword([]byte(config.AppConfig.AdminPassHash), []byte(req.Password)); err != nil { + jsonResponse(w, http.StatusUnauthorized, APIResponse{Success: false, Message: "密码不正确"}) + return + } + + config.AppConfig.AdminUser = req.NewUsername + if err := config.SaveConfig(); err != nil { + jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "保存配置失败"}) + return + } + + jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "用户名修改成功"}) +} diff --git a/backend/internal/api/ssh.go b/backend/internal/api/ssh.go new file mode 100644 index 0000000..c7213f3 --- /dev/null +++ b/backend/internal/api/ssh.go @@ -0,0 +1,308 @@ +package api + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "log" + "net" + "net/http" + "sync" + "time" + + "clicd/internal/config" + + "github.com/gorilla/websocket" + "golang.org/x/crypto/ssh" +) + +type terminalResizeMessage struct { + Type string `json:"type"` + Cols int `json:"cols"` + Rows int `json:"rows"` +} + +type webSSHTicket struct { + ContainerName string + ExpiresAt time.Time +} + +var webSSHTickets = struct { + sync.Mutex + items map[string]webSSHTicket +}{items: map[string]webSSHTicket{}} + +func HandleWebSSHTicket(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) + return + } + + var req struct { + ContainerName string `json:"container_name"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.ContainerName == "" { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Container name required"}) + return + } + if !isContainerAllowedForRequest(r, req.ContainerName) { + jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Access denied to this container"}) + return + } + if config.FindContainerByName(req.ContainerName) == nil { + jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"}) + return + } + + ticket := randomHex(32) + webSSHTickets.Lock() + cleanupExpiredWebSSHTicketsLocked(time.Now()) + webSSHTickets.items[ticket] = webSSHTicket{ + ContainerName: req.ContainerName, + ExpiresAt: time.Now().Add(60 * time.Second), + } + webSSHTickets.Unlock() + + jsonResponse(w, http.StatusOK, APIResponse{ + Success: true, + Data: map[string]string{"ticket": ticket}, + }) +} + +// HandleWebSSH proxies an SSH session to the browser over WebSocket. +func HandleWebSSH(w http.ResponseWriter, r *http.Request) { + ticket := r.URL.Query().Get("ticket") + if ticket == "" { + http.Error(w, "ticket required", http.StatusUnauthorized) + return + } + + containerName := r.URL.Query().Get("container") + if containerName == "" { + http.Error(w, "container name required", http.StatusBadRequest) + return + } + + if !consumeWebSSHTicket(ticket, containerName) { + http.Error(w, "invalid or expired ticket", http.StatusUnauthorized) + return + } + + c := config.FindContainerByName(containerName) + if c == nil { + http.Error(w, "container not found", http.StatusNotFound) + return + } + if c.Status != "running" { + http.Error(w, "container is not running", http.StatusBadRequest) + return + } + if c.IP == "" { + if ip, err := lxcManager.GetContainerIP(c.LxcName()); err == nil { + c.IP = ip + config.SaveConfig() + } + } + if c.IP == "" { + if ip, err := lxcManager.EnsureContainerIPv4(c.ID); err == nil && ip != "" { + c.IP = ip + } + } + if c.IP == "" { + http.Error(w, "container ip is not available", http.StatusBadRequest) + return + } + ws, err := upgrader.Upgrade(w, r, nil) + if err != nil { + log.Printf("WebSSH upgrade failed: %v", err) + return + } + defer ws.Close() + + if c.SSHPassword == "" { + writeWebSocketText(ws, nil, "\r\nPreparing SSH service. This can take up to 90 seconds on first boot...\r\n") + if err := lxcManager.EnsureSSH(c.ID); err != nil { + writeWebSocketText(ws, nil, fmt.Sprintf("\r\nSSH auto setup failed: %v\r\n", err)) + return + } + if refreshed := config.FindContainer(c.ID); refreshed != nil { + c = refreshed + } + } + if c.SSHPassword == "" { + writeWebSocketText(ws, nil, "\r\nSSH password is empty after auto setup\r\n") + return + } + + sshConfig := &ssh.ClientConfig{ + User: "root", + Auth: []ssh.AuthMethod{ + ssh.Password(c.SSHPassword), + }, + HostKeyCallback: ssh.InsecureIgnoreHostKey(), + Timeout: 4 * time.Second, + } + + addr := net.JoinHostPort(c.IP, "22") + writeWebSocketText(ws, nil, fmt.Sprintf("Connecting to %s...\r\n", addr)) + client, err := ssh.Dial("tcp", addr, sshConfig) + if err != nil { + writeWebSocketText(ws, nil, "\r\nSSH is not ready yet, preparing service. This can take up to 90 seconds on first boot...\r\n") + if setupErr := lxcManager.EnsureSSH(c.ID); setupErr != nil { + writeWebSocketText(ws, nil, fmt.Sprintf("\r\nSSH auto setup failed: %v\r\n", setupErr)) + return + } + if refreshed := config.FindContainer(c.ID); refreshed != nil { + c = refreshed + } + if ip, ipErr := lxcManager.GetContainerIP(c.LxcName()); ipErr == nil && ip != "" { + c.IP = ip + config.SaveConfig() + addr = net.JoinHostPort(c.IP, "22") + } + sshConfig.Auth = []ssh.AuthMethod{ssh.Password(c.SSHPassword)} + sshConfig.Timeout = 10 * time.Second + client, err = ssh.Dial("tcp", addr, sshConfig) + if err != nil { + writeWebSocketText(ws, nil, fmt.Sprintf("\r\nWebSSH connection failed: %v\r\n", err)) + return + } + } + defer client.Close() + + session, err := client.NewSession() + if err != nil { + writeWebSocketText(ws, nil, fmt.Sprintf("\r\nFailed to create SSH session: %v\r\n", err)) + return + } + defer session.Close() + + stdin, err := session.StdinPipe() + if err != nil { + writeWebSocketText(ws, nil, fmt.Sprintf("\r\nFailed to open SSH stdin: %v\r\n", err)) + return + } + + stdout, err := session.StdoutPipe() + if err != nil { + writeWebSocketText(ws, nil, fmt.Sprintf("\r\nFailed to open SSH stdout: %v\r\n", err)) + return + } + + stderr, err := session.StderrPipe() + if err != nil { + writeWebSocketText(ws, nil, fmt.Sprintf("\r\nFailed to open SSH stderr: %v\r\n", err)) + return + } + + if err := session.RequestPty("xterm-256color", 40, 120, ssh.TerminalModes{ + ssh.ECHO: 1, + ssh.TTY_OP_ISPEED: 14400, + ssh.TTY_OP_OSPEED: 14400, + }); err != nil { + writeWebSocketText(ws, nil, fmt.Sprintf("\r\nFailed to request pty: %v\r\n", err)) + return + } + + if err := session.Shell(); err != nil { + writeWebSocketText(ws, nil, fmt.Sprintf("\r\nFailed to start shell: %v\r\n", err)) + return + } + writeWebSocketText(ws, nil, "\r\nSSH shell ready. Press Enter if the prompt is not visible.\r\n") + _, _ = stdin.Write([]byte("\n")) + + log.Printf("WebSSH connected for container %s -> %s", containerName, addr) + + done := make(chan struct{}, 3) + var writeMu sync.Mutex + + go streamSSHOutput(ws, &writeMu, stdout, done) + go streamSSHOutput(ws, &writeMu, stderr, done) + + go func() { + defer func() { done <- struct{}{} }() + for { + messageType, msg, err := ws.ReadMessage() + if err != nil { + return + } + + if messageType == websocket.TextMessage { + var resize terminalResizeMessage + if err := json.Unmarshal(msg, &resize); err == nil && resize.Type == "resize" { + if resize.Rows > 0 && resize.Cols > 0 { + _ = session.WindowChange(resize.Rows, resize.Cols) + } + continue + } + } + + if _, err := stdin.Write(msg); err != nil { + return + } + } + }() + + <-done + _ = session.Signal(ssh.SIGTERM) + log.Printf("WebSSH disconnected for container %s", containerName) +} + +func streamSSHOutput(ws *websocket.Conn, writeMu *sync.Mutex, src io.Reader, done chan<- struct{}) { + defer func() { done <- struct{}{} }() + + buf := make([]byte, 8192) + for { + n, err := src.Read(buf) + if n > 0 { + writeMu.Lock() + writeErr := ws.WriteMessage(websocket.BinaryMessage, buf[:n]) + writeMu.Unlock() + if writeErr != nil { + return + } + } + if err != nil { + return + } + } +} + +func writeWebSocketText(ws *websocket.Conn, writeMu *sync.Mutex, msg string) { + if writeMu != nil { + writeMu.Lock() + defer writeMu.Unlock() + } + _ = ws.WriteMessage(websocket.TextMessage, []byte(msg)) +} + +func consumeWebSSHTicket(ticket, containerName string) bool { + now := time.Now() + webSSHTickets.Lock() + defer webSSHTickets.Unlock() + cleanupExpiredWebSSHTicketsLocked(now) + item, ok := webSSHTickets.items[ticket] + if !ok { + return false + } + delete(webSSHTickets.items, ticket) + return item.ContainerName == containerName && now.Before(item.ExpiresAt) +} + +func cleanupExpiredWebSSHTicketsLocked(now time.Time) { + for ticket, item := range webSSHTickets.items { + if !now.Before(item.ExpiresAt) { + delete(webSSHTickets.items, ticket) + } + } +} + +func randomHex(bytesLen int) string { + b := make([]byte, bytesLen) + if _, err := rand.Read(b); err != nil { + return fmt.Sprintf("%d", time.Now().UnixNano()) + } + return hex.EncodeToString(b) +} diff --git a/backend/internal/api/subuser.go b/backend/internal/api/subuser.go new file mode 100644 index 0000000..624a9ad --- /dev/null +++ b/backend/internal/api/subuser.go @@ -0,0 +1,422 @@ +package api + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" + + "clicd/internal/config" + + "github.com/golang-jwt/jwt/v5" + "golang.org/x/crypto/bcrypt" +) + +func generateRandomStr(length int) string { + b := make([]byte, length) + rand.Read(b) + return hex.EncodeToString(b)[:length] +} + +// HandleSubUserCreate creates a sub-user for a specific container +func HandleSubUserCreate(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) + return + } + + var req struct { + ContainerName string `json:"container_name"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"}) + return + } + + c := containerByIdentifier(req.ContainerName) + + if c == nil { + jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"}) + return + } + containerName := c.Name + + // Check if sub-user already exists for this container + for i := range config.AppConfig.SubUsers { + su := &config.AppConfig.SubUsers[i] + for _, cn := range su.ContainerNames { + if cn == containerName { + if su.AccessCode == "" { + su.AccessCode = generateRandomStr(8) + } + if su.PassHash == "" && su.Password != "" { + if hash, err := bcrypt.GenerateFromPassword([]byte(su.Password), bcrypt.DefaultCost); err == nil { + su.PassHash = string(hash) + } + } + if su.Password == "" { + su.Password = generateRandomStr(16) + if hash, err := bcrypt.GenerateFromPassword([]byte(su.Password), bcrypt.DefaultCost); err == nil { + su.PassHash = string(hash) + } + } + su.Token = newSubUserToken(su.Username, []string{c.UUID}, time.Now().AddDate(1, 0, 0)) + config.SaveConfig() + // Return existing + jsonResponse(w, http.StatusOK, APIResponse{ + Success: true, + Message: "Sub-user already exists", + Data: *su, + }) + return + } + } + } + + // Create new sub-user + username := "user-" + generateRandomStr(8) + password := generateRandomStr(16) + hash, _ := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + + // Generate short access code (8 chars, for URL sharing) + accessCode := generateRandomStr(8) + + // Generate JWT for sub-user + tokenStr := newSubUserToken(username, []string{c.UUID}, time.Now().AddDate(1, 0, 0)) + + subUser := config.SubUser{ + ID: "sub-" + generateRandomStr(8), + Username: username, + Password: password, + PassHash: string(hash), + ContainerNames: []string{containerName}, + Token: tokenStr, + AccessCode: accessCode, + CreatedAt: time.Now().Format("2006-01-02 15:04:05"), + } + + config.AppConfig.SubUsers = append(config.AppConfig.SubUsers, subUser) + config.SaveConfig() + config.AddAuditLog("创建子用户", containerName, fmt.Sprintf("用户: %s", username), "admin") + + jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Sub-user created", Data: subUser}) +} + +// HandleSubUserLogin handles sub-user login +func HandleSubUserLogin(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) + return + } + + var req struct { + Username string `json:"username"` + Password string `json:"password"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"}) + return + } + + // Find sub-user + for _, su := range config.AppConfig.SubUsers { + if su.Username == req.Username { + if err := bcrypt.CompareHashAndPassword([]byte(su.PassHash), []byte(req.Password)); err == nil { + // Generate fresh token + containerUUIDs := subUserContainerUUIDs(su.ContainerNames) + if len(containerUUIDs) == 0 { + jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "No active container is assigned to this user"}) + return + } + tokenStr := newSubUserToken(su.Username, containerUUIDs, time.Now().Add(24*time.Hour)) + + jsonResponse(w, http.StatusOK, APIResponse{ + Success: true, + Data: map[string]interface{}{ + "token": tokenStr, + "username": su.Username, + "container_uuids": containerUUIDs, + }, + }) + return + } + } + } + + jsonResponse(w, http.StatusUnauthorized, APIResponse{Success: false, Message: "Invalid credentials"}) +} + +// HandleSubUserAccessCode handles access via short code + password (no token in URL) +func HandleSubUserAccessCode(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) + return + } + + var req struct { + Code string `json:"code"` + Password string `json:"password"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"}) + return + } + + // Find sub-user by access code + for _, su := range config.AppConfig.SubUsers { + if su.AccessCode == req.Code { + if err := bcrypt.CompareHashAndPassword([]byte(su.PassHash), []byte(req.Password)); err != nil { + jsonResponse(w, http.StatusUnauthorized, APIResponse{Success: false, Message: "Invalid password"}) + return + } + + containerUUIDs := subUserContainerUUIDs(su.ContainerNames) + if len(containerUUIDs) == 0 { + jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "No active container is assigned to this link"}) + return + } + tokenStr := newSubUserToken(su.Username, containerUUIDs, time.Now().Add(24*time.Hour)) + + jsonResponse(w, http.StatusOK, APIResponse{ + Success: true, + Data: map[string]interface{}{ + "token": tokenStr, + "username": su.Username, + "container_uuids": containerUUIDs, + }, + }) + return + } + } + + jsonResponse(w, http.StatusUnauthorized, APIResponse{Success: false, Message: "Invalid access code"}) +} + +func newSubUserToken(username string, containerUUIDs []string, expiresAt time.Time) string { + token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ + "sub_user": username, + "container_uuids": containerUUIDs, + "exp": expiresAt.Unix(), + "iat": time.Now().Unix(), + }) + tokenStr, _ := token.SignedString([]byte(config.AppConfig.JWTSecret)) + return tokenStr +} + +type subUserAccess struct { + names map[string]bool + uuids map[string]bool +} + +func subUserAllowedContainers(r *http.Request) (subUserAccess, bool) { + claims, ok := claimsFromRequest(r) + if !ok { + return subUserAccess{}, false + } + if _, isSubUser := claims["sub_user"]; !isSubUser { + return subUserAccess{}, false + } + + allowed := subUserAccess{ + names: make(map[string]bool), + uuids: make(map[string]bool), + } + if containerNames, ok := claims["container_names"].([]interface{}); ok { + for _, cn := range containerNames { + if name, ok := cn.(string); ok { + allowed.names[name] = true + } + } + } + if containerNames, ok := claims["container_names"].([]string); ok { + for _, name := range containerNames { + allowed.names[name] = true + } + } + if containerUUIDs, ok := claims["container_uuids"].([]interface{}); ok { + for _, item := range containerUUIDs { + if uuid, ok := item.(string); ok { + allowed.uuids[uuid] = true + } + } + } + if containerUUIDs, ok := claims["container_uuids"].([]string); ok { + for _, uuid := range containerUUIDs { + allowed.uuids[uuid] = true + } + } + return allowed, true +} + +func containerByIdentifier(identifier string) *config.Container { + return config.FindContainerByIdentifier(identifier) +} + +func isContainerAllowedForRequest(r *http.Request, identifier string) bool { + allowed, isSubUser := subUserAllowedContainers(r) + if !isSubUser { + return true + } + c := containerByIdentifier(identifier) + if c == nil { + return false + } + return isContainerAllowed(allowed, c) +} + +// HandleAuditLogs returns audit logs +func HandleAuditLogs(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) + return + } + + logs := config.AppConfig.AuditLogs + if logs == nil { + logs = []config.AuditLog{} + } + // Return in reverse order (newest first) + reversed := make([]config.AuditLog, len(logs)) + for i, l := range logs { + reversed[len(logs)-1-i] = l + } + + jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: reversed}) +} + +// SubUserMiddleware checks if a request is from a sub-user and restricts container access +func SubUserMiddleware(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + allowed, isSubUser := subUserAllowedContainers(r) + if !isSubUser { + next(w, r) + return + } + + path := r.URL.Path + if path == "/api/tasks" && r.Method == http.MethodGet { + next(w, r) + return + } + + if path == "/api/containers" { + if r.Method != http.MethodGet { + jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Sub-users cannot create containers"}) + return + } + next(w, r) + return + } + + if len(path) > len("/api/containers/") { + rest := path[len("/api/containers/"):] + parts := splitPath(rest) + if len(parts) > 0 && parts[0] != "" { + c := containerByIdentifier(parts[0]) + if c == nil || !isContainerAllowed(allowed, c) { + jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Access denied to this container"}) + return + } + action := "" + if len(parts) > 1 { + action = parts[1] + } + if !isSubUserContainerActionAllowed(action, r.Method) { + jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Action is not allowed for this link"}) + return + } + } + next(w, r) + return + } + + jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Access denied"}) + return + } +} + +func filterContainersForRequest(r *http.Request, containers []config.Container) []config.Container { + allowed, isSubUser := subUserAllowedContainers(r) + if !isSubUser { + return containers + } + filtered := make([]config.Container, 0, len(containers)) + for _, c := range containers { + if isContainerAllowed(allowed, &c) { + filtered = append(filtered, c) + } + } + return filtered +} + +func filterTasksForRequest(r *http.Request, tasks []*Task) []*Task { + allowed, isSubUser := subUserAllowedContainers(r) + if !isSubUser { + return tasks + } + filtered := make([]*Task, 0, len(tasks)) + for _, task := range tasks { + if allowed.names[task.ContainerName] || (task.Config.Name != "" && allowed.names[task.Config.Name]) { + filtered = append(filtered, task) + } + } + return filtered +} + +func isContainerAllowed(allowed subUserAccess, c *config.Container) bool { + return allowed.names[c.Name] || (c.UUID != "" && allowed.uuids[c.UUID]) +} + +func isSubUserContainerActionAllowed(action string, method string) bool { + if action == "" { + return method == http.MethodGet + } + switch { + case action == "usage" || action == "traffic" || action == "random-port": + return method == http.MethodGet + case action == "start" || action == "stop" || action == "restart" || action == "reinstall": + return method == http.MethodPost + case strings.HasPrefix(action, "port-mappings/"): + return method == http.MethodPut + default: + return false + } +} + +func subUserContainerUUIDs(containerNames []string) []string { + uuids := make([]string, 0, len(containerNames)) + for _, name := range containerNames { + if c := config.FindContainerByName(name); c != nil && c.UUID != "" { + uuids = append(uuids, c.UUID) + } + } + return uuids +} + +func splitPath(path string) []string { + parts := make([]string, 0) + for _, p := range splitBy(path, "/") { + if p != "" { + parts = append(parts, p) + } + } + return parts +} + +func splitBy(s, sep string) []string { + result := make([]string, 0) + current := "" + for _, c := range s { + if string(c) == sep { + result = append(result, current) + current = "" + } else { + current += string(c) + } + } + result = append(result, current) + return result +} diff --git a/backend/internal/api/swap.go b/backend/internal/api/swap.go new file mode 100644 index 0000000..13ac209 --- /dev/null +++ b/backend/internal/api/swap.go @@ -0,0 +1,189 @@ +package api + +import ( + "encoding/json" + "fmt" + "net/http" + "os" + "os/exec" + "strconv" + "strings" +) + +type SwapInfo struct { + TotalMB int64 `json:"total_mb"` + UsedMB int64 `json:"used_mb"` + FreeMB int64 `json:"free_mb"` + Enabled bool `json:"enabled"` + SwapFile string `json:"swap_file"` +} + +// HandleSwapInfo returns current swap status +func HandleSwapInfo(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) + return + } + + info := getSwapInfo() + jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: info}) +} + +// HandleSwapManage creates/enables/disables swap +func HandleSwapManage(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) + return + } + + var req struct { + Action string `json:"action"` // create, enable, disable, resize + SizeMB int `json:"size_mb"` // for create/resize + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"}) + return + } + + var msg string + + switch req.Action { + case "create": + if req.SizeMB <= 0 { + req.SizeMB = 2048 + } + err := createSwap(req.SizeMB) + if err != nil { + jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()}) + return + } + msg = fmt.Sprintf("已创建 %d MB SWAP", req.SizeMB) + + case "enable": + err := enableSwap() + if err != nil { + jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()}) + return + } + msg = "SWAP 已启用" + + case "disable": + err := disableSwap() + if err != nil { + jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()}) + return + } + msg = "SWAP 已禁用" + + case "resize": + if req.SizeMB <= 0 { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid size"}) + return + } + disableSwap() + createSwap(req.SizeMB) + enableSwap() + msg = fmt.Sprintf("SWAP 已调整为 %d MB", req.SizeMB) + + default: + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid action: " + req.Action}) + return + } + + info := getSwapInfo() + jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: msg, Data: info}) +} + +func getSwapInfo() SwapInfo { + info := SwapInfo{SwapFile: "/swapfile"} + + // Read /proc/meminfo for swap stats + data, err := os.ReadFile("/proc/meminfo") + if err != nil { + return info + } + + lines := strings.Split(string(data), "\n") + for _, line := range lines { + fields := strings.Fields(line) + if len(fields) < 2 { + continue + } + val, _ := strconv.ParseInt(fields[1], 10, 64) + switch fields[0] { + case "SwapTotal:": + info.TotalMB = val / 1024 + case "SwapFree:": + info.FreeMB = val / 1024 + } + } + + info.UsedMB = info.TotalMB - info.FreeMB + if info.TotalMB > 0 { + info.Enabled = true + } + + return info +} + +func createSwap(sizeMB int) error { + swapFile := "/swapfile" + + // Check if swap file already exists + if _, err := os.Stat(swapFile); err == nil { + // Remove old swap file + exec.Command("swapoff", swapFile).Run() + os.Remove(swapFile) + } + + // Create swap file + cmd := exec.Command("dd", "if=/dev/zero", "of="+swapFile, "bs=1M", "count="+strconv.Itoa(sizeMB)) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("创建 swap 文件失败: %v, %s", err, string(output)) + } + + // Set permissions + os.Chmod(swapFile, 0600) + + // Make swap + cmd = exec.Command("mkswap", swapFile) + output, err = cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("mkswap 失败: %v, %s", err, string(output)) + } + + // Enable swap + return enableSwap() +} + +func enableSwap() error { + swapFile := "/swapfile" + if _, err := os.Stat(swapFile); os.IsNotExist(err) { + return fmt.Errorf("swap 文件不存在,请先创建") + } + + cmd := exec.Command("swapon", swapFile) + output, err := cmd.CombinedOutput() + if err != nil { + // Check if already enabled + if strings.Contains(string(output), "already") { + return nil + } + return fmt.Errorf("启用 swap 失败: %v, %s", err, string(output)) + } + return nil +} + +func disableSwap() error { + swapFile := "/swapfile" + cmd := exec.Command("swapoff", swapFile) + output, err := cmd.CombinedOutput() + if err != nil { + if strings.Contains(string(output), "No such") { + return nil + } + return fmt.Errorf("禁用 swap 失败: %v, %s", err, string(output)) + } + return nil +} diff --git a/backend/internal/api/taskqueue.go b/backend/internal/api/taskqueue.go new file mode 100644 index 0000000..bfd6207 --- /dev/null +++ b/backend/internal/api/taskqueue.go @@ -0,0 +1,650 @@ +package api + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + "sync" + "time" + + "clicd/internal/config" + "clicd/internal/lxc" +) + +type TaskType string + +const ( + TaskCreate TaskType = "create" + TaskStart TaskType = "start" + TaskStop TaskType = "stop" + TaskRestart TaskType = "restart" + TaskDelete TaskType = "delete" + TaskReinstall TaskType = "reinstall" +) + +type Task struct { + ID string `json:"id"` + Type TaskType `json:"type"` + ContainerID int `json:"container_id"` + ContainerName string `json:"container_name"` + Status string `json:"status"` + Error string `json:"error,omitempty"` + CreatedAt string `json:"created_at"` + TemplateID string `json:"template_id,omitempty"` + Config lxc.ContainerConfig `json:"config,omitempty"` + Name string `json:"name,omitempty"` + User string `json:"user,omitempty"` // who created this task +} + +type TaskQueue struct { + mu sync.Mutex + createQueue []*Task + opQueue []*Task + tasks map[string]*Task + nextID int + createCond *sync.Cond + opCond *sync.Cond + stop chan struct{} +} + +var globalQueue *TaskQueue + +func init() { + globalQueue = &TaskQueue{ + tasks: make(map[string]*Task), + stop: make(chan struct{}), + } + globalQueue.createCond = sync.NewCond(&globalQueue.mu) + globalQueue.opCond = sync.NewCond(&globalQueue.mu) + go globalQueue.createWorker() + go globalQueue.opWorker() +} + +func (q *TaskQueue) enqueueTask(task *Task) { + q.tasks[task.ID] = task + if task.Type == TaskCreate { + q.createQueue = append(q.createQueue, task) + q.createCond.Signal() + } else { + q.opQueue = append(q.opQueue, task) + q.opCond.Signal() + } +} + +func (q *TaskQueue) Enqueue(containerID int, containerName string, taskType TaskType, templateID string, cfg *lxc.ContainerConfig) []string { + q.mu.Lock() + defer q.mu.Unlock() + + id := q.nextID + q.nextID++ + task := &Task{ + ID: fmt.Sprintf("task-%d", id), + Type: taskType, + ContainerID: containerID, + ContainerName: containerName, + Status: "pending", + CreatedAt: time.Now().Format("2006-01-02 15:04:05"), + TemplateID: templateID, + } + if cfg != nil { + task.Config = *cfg + } + q.enqueueTask(task) + q.persistTasks() + return []string{task.ID} +} + +func (q *TaskQueue) EnqueueBatch(taskType TaskType, ids []int, templateID string) []string { + return q.EnqueueBatchWithUser(taskType, ids, templateID, "admin") +} + +func (q *TaskQueue) EnqueueBatchWithUser(taskType TaskType, ids []int, templateID string, user string) []string { + q.mu.Lock() + defer q.mu.Unlock() + var result []string + for _, id := range ids { + c := config.FindContainer(id) + name := "" + if c != nil { + name = c.Name + } + result = append(result, q.enqueueSingleWithUser(id, name, taskType, templateID, user)) + } + q.persistTasks() + return result +} + +func (q *TaskQueue) EnqueueBatchCreate(configs []lxc.ContainerConfig) []string { + q.mu.Lock() + defer q.mu.Unlock() + return q.enqueueBatchCreateList(configs) +} + +func (q *TaskQueue) ActiveCreateNames() map[string]bool { + q.mu.Lock() + defer q.mu.Unlock() + + names := make(map[string]bool) + for _, task := range q.tasks { + if task.Type != TaskCreate || (task.Status != "pending" && task.Status != "running") { + continue + } + name := task.Config.Name + if name == "" { + name = task.ContainerName + } + if name != "" { + names[name] = true + } + } + return names +} + +func (q *TaskQueue) enqueueBatchCreateList(configs []lxc.ContainerConfig) []string { + var result []string + for _, cfg := range configs { + cfgCopy := cfg + id := q.nextID + q.nextID++ + task := &Task{ + ID: fmt.Sprintf("task-%d", id), + Type: TaskCreate, + ContainerID: 0, + ContainerName: cfgCopy.Name, + Status: "pending", + CreatedAt: time.Now().Format("2006-01-02 15:04:05"), + Config: cfgCopy, + } + q.enqueueTask(task) + result = append(result, task.ID) + } + q.persistTasks() + return result +} + +func (q *TaskQueue) enqueueSingle(containerID int, containerName string, taskType TaskType, templateID string) string { + return q.enqueueSingleWithUser(containerID, containerName, taskType, templateID, "admin") +} + +func (q *TaskQueue) enqueueSingleWithUser(containerID int, containerName string, taskType TaskType, templateID string, user string) string { + id := q.nextID + q.nextID++ + task := &Task{ + ID: fmt.Sprintf("task-%d", id), + Type: taskType, + ContainerID: containerID, + ContainerName: containerName, + Status: "pending", + CreatedAt: time.Now().Format("2006-01-02 15:04:05"), + TemplateID: templateID, + User: user, + } + q.enqueueTask(task) + return task.ID +} + +// createWorker handles TaskCreate: lxc-create, resource setup, start, and SSH init. +// If a restored task already has a same-name container in config, it resumes +// initialization instead of creating another ct-{id}. +func (q *TaskQueue) createWorker() { + for { + q.mu.Lock() + for len(q.createQueue) == 0 { + q.createCond.Wait() + } + task := q.createQueue[0] + q.createQueue = q.createQueue[1:] + task.Status = "running" + q.mu.Unlock() + + createdByTask := false + if task.Config.Name == "" { + task.Config.Name = task.ContainerName + } + if task.Config.Name == "" { + task.Status = "failed" + task.Error = "container name is required" + config.AddAuditLog(string(task.Type), task.ContainerName, "failed: "+task.Error, "admin") + q.mu.Lock() + q.persistTasks() + q.mu.Unlock() + continue + } + c := config.FindContainerByName(task.Config.Name) + if c == nil { + // 1) Download image + apply limits (lxc-create) + err := lxcManager.CreateContainer(task.Config) + if err != nil { + task.Status = "failed" + task.Error = err.Error() + config.AddAuditLog(string(task.Type), task.Config.Name, "失败: "+err.Error(), "admin") + q.mu.Lock() + q.persistTasks() + q.mu.Unlock() + continue + } + createdByTask = true + + // 2) Find created container by name + c = config.FindContainerByName(task.Config.Name) + if c == nil { + task.Status = "failed" + task.Error = "created but not found in config" + config.AddAuditLog(string(task.Type), task.Config.Name, "失败: "+task.Error, "admin") + q.mu.Lock() + q.persistTasks() + q.mu.Unlock() + continue + } + } + + task.ContainerID = c.ID + task.ContainerName = c.Name + + // 3) Start + initialize SSH/network in the same worker. + // If init fails, destroy the container so no dead entry remains. + startErr := lxcManager.StartContainer(c.ID) + if startErr != nil { + if createdByTask { + lxcManager.DestroyContainer(c.ID) + } + task.Status = "failed" + task.Error = startErr.Error() + config.AddAuditLog(string(task.Type), task.ContainerName, "初始化失败: "+startErr.Error(), "admin") + } else { + task.Status = "done" + config.AddAuditLog(string(task.Type), task.ContainerName, "成功", "admin") + } + + q.mu.Lock() + q.persistTasks() + q.mu.Unlock() + } +} + +// opWorker handles all non-create tasks (start, stop, restart, delete, reinstall) +// including the follow-up initialization after a create succeeds. +func (q *TaskQueue) opWorker() { + for { + q.mu.Lock() + for len(q.opQueue) == 0 { + q.opCond.Wait() + } + task := q.opQueue[0] + q.opQueue = q.opQueue[1:] + task.Status = "running" + q.mu.Unlock() + + var err error + err = resolveTaskContainer(task) + // Block operations on expired or traffic-exceeded containers (except stop/delete) + if err == nil && (task.Type == TaskStart || task.Type == TaskRestart || task.Type == TaskReinstall) { + c := config.FindContainer(task.ContainerID) + if c != nil { + if lxc.IsExpired(*c) { + err = fmt.Errorf("容器已到期,不允许此操作") + } else if lxc.IsTrafficExceeded(*c) { + err = fmt.Errorf("容器流量已超限,不允许此操作") + } + } + } + if err == nil { + switch task.Type { + case TaskStart: + err = lxcManager.StartContainer(task.ContainerID) + case TaskStop: + err = lxcManager.StopContainer(task.ContainerID) + case TaskRestart: + err = lxcManager.RestartContainer(task.ContainerID) + case TaskDelete: + err = lxcManager.DestroyContainer(task.ContainerID) + if err == nil { + time.Sleep(1 * time.Second) + if config.FindContainer(task.ContainerID) != nil { + err = fmt.Errorf("container still exists after delete: %d", task.ContainerID) + } + } + case TaskReinstall: + err = lxcManager.ReinstallContainer(task.ContainerID, task.TemplateID) + } + } + + q.mu.Lock() + auditUser := task.User + if auditUser == "" { + auditUser = "admin" + } + if err != nil { + task.Status = "failed" + task.Error = err.Error() + config.AddAuditLog(string(task.Type), task.ContainerName, "失败: "+err.Error(), auditUser) + } else { + task.Status = "done" + config.AddAuditLog(string(task.Type), task.ContainerName, "成功", auditUser) + switch task.Type { + case TaskStart: + config.UpdateContainerStatus(task.ContainerID, "running") + case TaskStop: + config.UpdateContainerStatus(task.ContainerID, "stopped") + case TaskRestart: + config.UpdateContainerStatus(task.ContainerID, "running") + } + } + q.persistTasks() + q.mu.Unlock() + } +} + +func resolveTaskContainer(task *Task) error { + if task.Type == TaskCreate { + return nil + } + if task.ContainerID > 0 { + if c := config.FindContainer(task.ContainerID); c != nil { + if task.ContainerName == "" { + task.ContainerName = c.Name + } + return nil + } + } + if task.ContainerName != "" { + if c := config.FindContainerByName(task.ContainerName); c != nil { + task.ContainerID = c.ID + task.ContainerName = c.Name + return nil + } + return fmt.Errorf("container not found: %s", task.ContainerName) + } + return fmt.Errorf("container not found: %d", task.ContainerID) +} + +func (q *TaskQueue) persistTasks() { + saved := make([]config.SavedTask, 0) + for _, t := range q.tasks { + // Only persist pending and running tasks to avoid + // re-queuing already completed/failed tasks after restart. + if t.Status != "pending" && t.Status != "running" { + continue + } + cfgJSON, _ := json.Marshal(t.Config) + saved = append(saved, config.SavedTask{ + ID: t.ID, + Type: string(t.Type), + ContainerID: t.ContainerID, + ContainerName: t.ContainerName, + Status: t.Status, + Error: t.Error, + CreatedAt: t.CreatedAt, + TemplateID: t.TemplateID, + Config: string(cfgJSON), + User: t.User, + }) + } + config.SaveTasks(saved) +} + +func (q *TaskQueue) GetTasks() []*Task { + q.mu.Lock() + defer q.mu.Unlock() + result := make([]*Task, 0, len(q.tasks)) + // Collect all task IDs, sort by creation time (extracted from ID number) + for _, t := range q.tasks { + result = append(result, t) + } + // Stable sort by ID number (task-N where N is sequential) + for i := 0; i < len(result); i++ { + for j := i + 1; j < len(result); j++ { + if parseIDNum(result[i].ID) > parseIDNum(result[j].ID) { + result[i], result[j] = result[j], result[i] + } + } + } + return result +} + +// HandleSingleTaskAction creates a task for a single container action +func HandleSingleTaskAction(w http.ResponseWriter, r *http.Request, id int, action string) { + c := config.FindContainer(id) + name := "" + if c != nil { + name = c.Name + } + + // Determine user from JWT claims + user := "admin" + if claims, ok := claimsFromRequest(r); ok { + if subUser, _ := claims["sub_user"].(string); subUser != "" { + user = "user:" + subUser + } + } + + var taskType TaskType + var templateID string + switch action { + case "start": + taskType = TaskStart + case "stop": + taskType = TaskStop + case "restart": + taskType = TaskRestart + case "delete": + taskType = TaskDelete + case "reinstall": + var req struct { + TemplateID string `json:"template_id"` + } + json.NewDecoder(r.Body).Decode(&req) + templateID = req.TemplateID + if templateID == "" { + c := config.FindContainer(id) + if c != nil { + templateID = c.Template + } + } + taskType = TaskReinstall + default: + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Unknown action"}) + return + } + + ids := globalQueue.EnqueueBatchWithUser(taskType, []int{id}, templateID, user) + jsonResponse(w, http.StatusAccepted, APIResponse{ + Success: true, + Message: "Task queued", + Data: map[string]interface{}{"task_id": ids[0], "container_name": name, "status": "pending"}, + }) +} + +// HandleBatchCreate handles batch container creation +func HandleBatchCreate(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) + return + } + var req struct { + Containers []lxc.ContainerConfig `json:"containers"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"}) + return + } + if len(req.Containers) == 0 { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "No containers requested"}) + return + } + + activeCreateNames := globalQueue.ActiveCreateNames() + requestNames := make(map[string]bool) + for i := range req.Containers { + name := strings.TrimSpace(req.Containers[i].Name) + req.Containers[i].Name = name + if !config.IsValidContainerNameSyntax(name) { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid container name: " + name}) + return + } + if requestNames[name] { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Duplicate container name in request: " + name}) + return + } + if config.FindContainerByName(name) != nil { + jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: "Container name already exists: " + name}) + return + } + if activeCreateNames[name] { + jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: "Container creation already queued: " + name}) + return + } + if req.Containers[i].VCPU <= 0 { + req.Containers[i].VCPU = 1 + } + if req.Containers[i].RAMMB < 128 { + req.Containers[i].RAMMB = 512 + } + if req.Containers[i].DiskGB < 1 { + req.Containers[i].DiskGB = 5 + } + if err := validateContainerResourceRequest(req.Containers[i].VCPU, req.Containers[i].RAMMB, req.Containers[i].DiskGB); err != nil { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: name + ": " + err.Error()}) + return + } + requestNames[name] = true + } + ids := globalQueue.EnqueueBatchCreate(req.Containers) + jsonResponse(w, http.StatusAccepted, APIResponse{Success: true, Data: ids}) +} + +// HandleBatchAction handles batch container actions +func HandleBatchAction(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) + return + } + var req struct { + Action string `json:"action"` + Containers []int `json:"containers"` + TemplateID string `json:"template_id,omitempty"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"}) + return + } + + var taskType TaskType + switch req.Action { + case "start": + taskType = TaskStart + case "stop": + taskType = TaskStop + case "restart": + taskType = TaskRestart + case "delete": + taskType = TaskDelete + default: + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Unknown action"}) + return + } + + ids := globalQueue.EnqueueBatch(taskType, req.Containers, req.TemplateID) + jsonResponse(w, http.StatusAccepted, APIResponse{Success: true, Data: ids}) +} + +// HandleTaskDelete deletes a specific task by ID +func HandleTaskDelete(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) + return + } + // URL: /api/tasks/{id} + taskID := strings.TrimPrefix(r.URL.Path, "/api/tasks/") + if taskID == "" { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Task ID required"}) + return + } + globalQueue.mu.Lock() + delete(globalQueue.tasks, taskID) + // Also remove from both queues if pending + newCreate := make([]*Task, 0, len(globalQueue.createQueue)) + for _, t := range globalQueue.createQueue { + if t.ID != taskID { + newCreate = append(newCreate, t) + } + } + globalQueue.createQueue = newCreate + newOp := make([]*Task, 0, len(globalQueue.opQueue)) + for _, t := range globalQueue.opQueue { + if t.ID != taskID { + newOp = append(newOp, t) + } + } + globalQueue.opQueue = newOp + globalQueue.persistTasks() + globalQueue.mu.Unlock() + jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Task deleted"}) +} + +// HandleTasks returns the current task queue +func HandleTasks(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) + return + } + tasks := globalQueue.GetTasks() + tasks = filterTasksForRequest(r, tasks) + jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: tasks}) +} + +// RestoreTasks restores task queue from config +func RestoreTasks() { + for _, st := range config.AppConfig.Tasks { + var cfg lxc.ContainerConfig + if st.Config != "" { + json.Unmarshal([]byte(st.Config), &cfg) + } + containerName := st.ContainerName + if containerName == "" { + containerName = cfg.Name + } + if cfg.Name == "" { + cfg.Name = containerName + } + containerID := st.ContainerID + if containerID <= 0 && containerName != "" { + if c := config.FindContainerByName(containerName); c != nil { + containerID = c.ID + } + } + globalQueue.tasks[st.ID] = &Task{ + ID: st.ID, + Type: TaskType(st.Type), + ContainerID: containerID, + ContainerName: containerName, + Status: st.Status, + Error: st.Error, + CreatedAt: st.CreatedAt, + TemplateID: st.TemplateID, + Config: cfg, + User: st.User, + } + if st.Status == "pending" || st.Status == "running" { + // Reset running tasks back to pending so they get retried + globalQueue.tasks[st.ID].Status = "pending" + globalQueue.enqueueTask(globalQueue.tasks[st.ID]) + } + if num := parseIDNum(st.ID); num >= globalQueue.nextID { + globalQueue.nextID = num + 1 + } + } + // Clear persisted tasks from disk (they're now in memory) + config.SaveTasks([]config.SavedTask{}) +} + +func parseIDNum(id string) int { + var num int + for _, c := range id { + if c >= '0' && c <= '9' { + num = num*10 + int(c-'0') + } + } + return num +} diff --git a/backend/internal/api/websocket.go b/backend/internal/api/websocket.go new file mode 100644 index 0000000..337cf98 --- /dev/null +++ b/backend/internal/api/websocket.go @@ -0,0 +1,35 @@ +package api + +import ( + "net" + "net/http" + "net/url" + "strings" + + "github.com/gorilla/websocket" +) + +var upgrader = websocket.Upgrader{ + ReadBufferSize: 1024, + WriteBufferSize: 1024, + CheckOrigin: func(r *http.Request) bool { + origin := r.Header.Get("Origin") + if origin == "" { + return true + } + originURL, err := url.Parse(origin) + if err != nil { + return false + } + originHost := strings.ToLower(stripPort(originURL.Host)) + requestHost := strings.ToLower(stripPort(r.Host)) + return originHost != "" && originHost == requestHost + }, +} + +func stripPort(host string) string { + if parsedHost, _, err := net.SplitHostPort(host); err == nil { + return parsedHost + } + return strings.Trim(host, "[]") +} diff --git a/backend/internal/cli/cli.go b/backend/internal/cli/cli.go new file mode 100644 index 0000000..1693734 --- /dev/null +++ b/backend/internal/cli/cli.go @@ -0,0 +1,426 @@ +package cli + +import ( + "bufio" + "fmt" + "os" + "os/exec" + "strconv" + "strings" + + "clicd/internal/config" + "clicd/internal/lxc" +) + +var manager = lxc.NewManager() + +// Run starts the CLI interface. +func Run() { + reader := bufio.NewReader(os.Stdin) + + for { + clearScreen() + printMenu() + fmt.Print("\nSelect action [1-9,0/q]: ") + input, _ := reader.ReadString('\n') + input = strings.TrimSpace(input) + + switch strings.ToLower(input) { + case "1": + clearScreen() + cliListContainers() + waitEnter(reader) + case "2": + clearScreen() + cliCreateContainer(reader) + waitEnter(reader) + case "3": + clearScreen() + cliStartContainer(reader) + waitEnter(reader) + case "4": + clearScreen() + cliStopContainer(reader) + waitEnter(reader) + case "5": + clearScreen() + cliRestartContainer(reader) + waitEnter(reader) + case "6": + clearScreen() + cliDeleteContainer(reader) + waitEnter(reader) + case "7": + clearScreen() + cliReinstallContainer(reader) + waitEnter(reader) + case "8": + clearScreen() + cliResetPassword(reader) + waitEnter(reader) + case "9": + clearScreen() + cliToggleWebPanel() + waitEnter(reader) + case "0": + clearScreen() + cliShowInfo() + waitEnter(reader) + case "q", "exit", "quit": + fmt.Println("Bye") + return + default: + fmt.Println("Invalid selection") + } + } +} + +func printMenu() { + webStatus := "start" + if isWebPanelRunning() { + webStatus = "stop" + } + fmt.Println() + fmt.Println(" ==========================================") + fmt.Println(" CLICD - LXC Container Manager") + fmt.Println(" ==========================================") + fmt.Println() + fmt.Printf(" Web panel: %s (port %d)\n", func() string { + if isWebPanelRunning() { + return "running" + } + return "stopped" + }(), config.AppConfig.Port) + fmt.Println() + fmt.Println(" 1. List containers") + fmt.Println(" 2. Create container") + fmt.Println(" 3. Start container") + fmt.Println(" 4. Stop container") + fmt.Println(" 5. Restart container") + fmt.Println(" 6. Delete container") + fmt.Println(" 7. Reinstall container") + fmt.Println(" 8. Reset web admin password") + fmt.Printf(" 9. %s web panel\n", webStatus) + fmt.Println(" 0. System info") + fmt.Println(" q. Quit") +} + +func cliListContainers() { + containers, err := manager.ListContainers() + if err != nil { + fmt.Printf("Failed to list containers: %v\n", err) + return + } + + if len(containers) == 0 { + fmt.Println("\nNo containers") + return + } + + fmt.Println() + fmt.Printf("%-18s %-10s %-18s %-6s %-10s %-10s %-16s\n", "Name", "Status", "Template", "vCPU", "RAM(MB)", "Disk(GB)", "SSH") + fmt.Println(strings.Repeat("-", 94)) + for _, c := range containers { + ssh := "-" + if c.SSHPort > 0 { + ssh = fmt.Sprintf("%d->22", c.SSHPort) + } + fmt.Printf("%-18s %-10s %-18s %-6.2f %-10d %-10d %-16s\n", + c.Name, c.Status, c.Template, c.VCPU, c.RAMMB, c.DiskGB, ssh) + } +} + +func cliCreateContainer(reader *bufio.Reader) { + fmt.Println("\n--- Create container ---") + + name := promptString(reader, "Container name", "") + if name == "" { + fmt.Println("Container name is required") + return + } + + templates := lxc.GetTemplates() + fmt.Println("\nAvailable templates:") + for i, template := range templates { + fmt.Printf(" %d. %s\n", i+1, template.Name) + } + + tmplIdx := promptInt(reader, fmt.Sprintf("Template [1-%d]", len(templates)), 1) + if tmplIdx < 1 || tmplIdx > len(templates) { + fmt.Println("Invalid template selection") + return + } + + cfg := lxc.ContainerConfig{ + Name: name, + TemplateID: templates[tmplIdx-1].ID, + VCPU: promptFloat(reader, "vCPU", 1), + RAMMB: promptInt(reader, "Memory (MB)", 512), + DiskGB: promptInt(reader, "Disk (GB)", 10), + NetworkBWMbps: promptInt(reader, "Network bandwidth (Mbps)", 100), + MonthlyTrafficGB: promptInt(reader, "Monthly traffic (GB)", 1000), + IOSpeedMBps: promptInt(reader, "IO speed (MB/s)", 500), + ExtraPorts: promptPortList(reader, "Extra NAT ports, comma separated"), + } + + fmt.Printf("\nCreating container %s ...\n", name) + if err := manager.CreateContainer(cfg); err != nil { + fmt.Printf("Create failed: %v\n", err) + return + } + + container := config.FindContainerByName(name) + fmt.Printf("Container %s created successfully\n", name) + if container != nil { + fmt.Printf("SSH: root / %s, port %d -> 22\n", container.SSHPassword, container.SSHPort) + } +} + +func cliStartContainer(reader *bufio.Reader) { + id, name := selectContainer(reader, "start") + if id == 0 { + return + } + if err := manager.StartContainer(id); err != nil { + fmt.Printf("Start failed: %v\n", err) + return + } + fmt.Printf("Container %s started\n", name) +} + +func cliStopContainer(reader *bufio.Reader) { + id, name := selectContainer(reader, "stop") + if id == 0 { + return + } + if err := manager.StopContainer(id); err != nil { + fmt.Printf("Stop failed: %v\n", err) + return + } + fmt.Printf("Container %s stopped\n", name) +} + +func cliRestartContainer(reader *bufio.Reader) { + id, name := selectContainer(reader, "restart") + if id == 0 { + return + } + if err := manager.RestartContainer(id); err != nil { + fmt.Printf("Restart failed: %v\n", err) + return + } + fmt.Printf("Container %s restarted\n", name) +} + +func cliDeleteContainer(reader *bufio.Reader) { + id, name := selectContainer(reader, "delete") + if id == 0 { + return + } + confirm := promptString(reader, fmt.Sprintf("Delete container %s? Type yes", name), "no") + if strings.ToLower(confirm) != "yes" { + fmt.Println("Canceled") + return + } + if err := manager.DestroyContainer(id); err != nil { + fmt.Printf("Delete failed: %v\n", err) + return + } + fmt.Printf("Container %s deleted\n", name) +} + +func cliReinstallContainer(reader *bufio.Reader) { + id, name := selectContainer(reader, "reinstall") + if id == 0 { + return + } + + templates := lxc.GetTemplates() + fmt.Println("\nAvailable templates:") + for i, template := range templates { + fmt.Printf(" %d. %s\n", i+1, template.Name) + } + + tmplIdx := promptInt(reader, fmt.Sprintf("Template [1-%d]", len(templates)), 1) + if tmplIdx < 1 || tmplIdx > len(templates) { + fmt.Println("Invalid template selection") + return + } + + confirm := promptString(reader, fmt.Sprintf("Reinstall container %s? Type yes", name), "no") + if strings.ToLower(confirm) != "yes" { + fmt.Println("Canceled") + return + } + + if err := manager.ReinstallContainer(id, templates[tmplIdx-1].ID); err != nil { + fmt.Printf("Reinstall failed: %v\n", err) + return + } + fmt.Printf("Container %s reinstalled\n", name) +} + +func cliResetPassword(reader *bufio.Reader) { + newPass := promptString(reader, "New admin password (at least 6 chars)", "") + if len(newPass) < 6 { + fmt.Println("Password must be at least 6 chars") + return + } + confirm := promptString(reader, "Confirm password", "") + if newPass != confirm { + fmt.Println("Passwords do not match") + return + } + + if err := config.ResetAdminPassword(newPass); err != nil { + fmt.Printf("Reset failed: %v\n", err) + return + } + fmt.Println("Admin password reset. Restart the web service for it to take effect.") +} + +func cliToggleWebPanel() { + if isWebPanelRunning() { + cmd := exec.Command("systemctl", "stop", "clicd") + if err := cmd.Run(); err != nil { + fmt.Printf("Failed to stop web panel: %v\n", err) + return + } + fmt.Println("Web panel stopped. LXC containers are not affected.") + return + } + + cmd := exec.Command("systemctl", "start", "clicd") + if err := cmd.Run(); err != nil { + fmt.Printf("Failed to start web panel: %v\n", err) + return + } + fmt.Println("Web panel started") +} + +func isWebPanelRunning() bool { + cmd := exec.Command("systemctl", "is-active", "clicd") + output, err := cmd.Output() + if err != nil { + return false + } + return strings.TrimSpace(string(output)) == "active" +} + +func cliShowInfo() { + containers, err := manager.ListContainers() + if err != nil { + fmt.Printf("Failed to read container status: %v\n", err) + } + + total := len(containers) + running := 0 + for _, container := range containers { + if container.Status == "running" { + running++ + } + } + + fmt.Println("\n--- System info ---") + fmt.Printf("Web port: %d\n", config.AppConfig.Port) + fmt.Printf("Admin user: %s\n", config.AppConfig.AdminUser) + fmt.Printf("Containers: %d\n", total) + fmt.Printf("Running: %d\n", running) + fmt.Printf("Stopped: %d\n", total-running) + + if hostname, err := os.Hostname(); err == nil { + fmt.Printf("Hostname: %s\n", hostname) + } + + cmd := exec.Command("lxc-info", "--version") + output, err := cmd.Output() + if err == nil { + fmt.Printf("LXC version: %s", string(output)) + } +} + +func selectContainer(reader *bufio.Reader, action string) (int, string) { + containers, err := manager.ListContainers() + if err != nil { + fmt.Printf("Failed to list containers: %v\n", err) + return 0, "" + } + if len(containers) == 0 { + fmt.Println("No containers available") + return 0, "" + } + + fmt.Printf("\n--- Select container to %s ---\n", action) + for i, container := range containers { + fmt.Printf(" %d. [%d] %s [%s]\n", i+1, container.ID, container.Name, container.Status) + } + + idx := promptInt(reader, "Container", 0) + if idx < 1 || idx > len(containers) { + fmt.Println("Invalid selection") + return 0, "" + } + + c := containers[idx-1] + return c.ID, c.Name +} + +func promptString(reader *bufio.Reader, label string, fallback string) string { + if fallback == "" { + fmt.Printf("%s: ", label) + } else { + fmt.Printf("%s [%s]: ", label, fallback) + } + + input, _ := reader.ReadString('\n') + input = strings.TrimSpace(input) + if input == "" { + return fallback + } + return input +} + +func promptInt(reader *bufio.Reader, label string, fallback int) int { + input := promptString(reader, label, strconv.Itoa(fallback)) + value, err := strconv.Atoi(input) + if err != nil || value < 0 { + return fallback + } + return value +} + +func promptFloat(reader *bufio.Reader, label string, fallback float64) float64 { + input := promptString(reader, label, strconv.FormatFloat(fallback, 'f', -1, 64)) + value, err := strconv.ParseFloat(input, 64) + if err != nil || value < 0 { + return fallback + } + return value +} + +func clearScreen() { + fmt.Print("\033[H\033[2J") +} + +func waitEnter(reader *bufio.Reader) { + fmt.Print("\nPress Enter to return to menu...") + reader.ReadString('\n') +} + +func promptPortList(reader *bufio.Reader, label string) []int { + input := promptString(reader, label, "") + if input == "" { + return nil + } + + var ports []int + for _, part := range strings.Split(input, ",") { + value, err := strconv.Atoi(strings.TrimSpace(part)) + if err != nil || value <= 0 || value > 65535 { + fmt.Printf("Ignoring invalid port: %s\n", strings.TrimSpace(part)) + continue + } + ports = append(ports, value) + } + return ports +} diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go new file mode 100644 index 0000000..25c09e5 --- /dev/null +++ b/backend/internal/config/config.go @@ -0,0 +1,586 @@ +package config + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "golang.org/x/crypto/bcrypt" +) + +// PortMapping represents a port mapping rule +type PortMapping struct { + ContainerPort int `json:"container_port"` + HostPort int `json:"host_port"` + Protocol string `json:"protocol"` + Description string `json:"description"` +} + +// SavedTask for persisting task queue across restarts +type SavedTask struct { + ID string `json:"id"` + Type string `json:"type"` + ContainerID int `json:"container_id"` + ContainerName string `json:"container_name"` + Status string `json:"status"` + Error string `json:"error,omitempty"` + CreatedAt string `json:"created_at"` + TemplateID string `json:"template_id,omitempty"` + Config string `json:"config,omitempty"` + User string `json:"user,omitempty"` +} + +// SavedLoginLog for persisting login logs +type SavedLoginLog struct { + Time string `json:"time"` + Username string `json:"username"` + IP string `json:"ip"` + UserAgent string `json:"user_agent"` + Success bool `json:"success"` +} + +// AuditLog represents an operation log entry +type AuditLog struct { + Time string `json:"time"` + Action string `json:"action"` + Target string `json:"target"` + Detail string `json:"detail"` + User string `json:"user"` +} + +// OversellConfig controls host-level overselling behavior +type OversellConfig struct { + CPUOvercommit int `json:"cpu_overcommit"` // multiplier, e.g. 4 means 4x oversell + RAMOvercommit int `json:"ram_overcommit"` // multiplier + DiskOvercommit int `json:"disk_overcommit"` // multiplier + KSMEnabled bool `json:"ksm_enabled"` // kernel same-page merging + Swappiness int `json:"swappiness"` // 0-100, lower = less swap +} + +// Container represents an LXC container configuration +type Container struct { + ID int `json:"id"` + UUID string `json:"uuid"` + Name string `json:"name"` + Template string `json:"template"` + VCPU float64 `json:"vcpu"` + 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"` // "total" or "in_out" + TrafficInGB int `json:"traffic_in_gb"` // 0 = unlimited + TrafficOutGB int `json:"traffic_out_gb"` // 0 = unlimited + TrafficUsedRX int64 `json:"traffic_used_rx"` + TrafficUsedTX int64 `json:"traffic_used_tx"` + TrafficResetDate string `json:"traffic_reset_date"` + IOSpeedMBps int `json:"io_speed_mbps"` + Status string `json:"status"` + IP string `json:"ip"` + IPv6 string `json:"ipv6"` + IPv6PrefixLen int `json:"ipv6_prefix_len"` + IPv6Interface string `json:"ipv6_interface"` + VNCPort int `json:"vnc_port"` + SSHPort int `json:"ssh_port"` + SSHPassword string `json:"ssh_password"` + PortMappings []PortMapping `json:"port_mappings"` + PortMappingLimit int `json:"port_mapping_limit"` + CreatedAt string `json:"created_at"` + ExpiresAt string `json:"expires_at"` +} + +// LxcName returns the internal LXC container name (ct-{id}) +func (c *Container) LxcName() string { + return fmt.Sprintf("ct-%d", c.ID) +} + +// SubUser represents a sub-user with access to specific containers +type ApiKeyConfig struct { + ID string `json:"id"` + Name string `json:"name"` + KeyHash string `json:"key_hash"` + Prefix string `json:"prefix"` + IPWhitelist string `json:"ip_whitelist"` + CreatedAt string `json:"created_at"` + LastUsed string `json:"last_used"` +} + +// DeleteApiKey removes an API key by ID +func DeleteApiKey(id string) { + filtered := make([]ApiKeyConfig, 0, len(AppConfig.ApiKeys)) + for _, k := range AppConfig.ApiKeys { + if k.ID != id { + filtered = append(filtered, k) + } + } + AppConfig.ApiKeys = filtered + SaveConfig() +} + +type SubUser struct { + ID string `json:"id"` + Username string `json:"username"` + Password string `json:"password"` // plaintext for display + PassHash string `json:"pass_hash"` + ContainerNames []string `json:"container_names"` + Token string `json:"token"` + AccessCode string `json:"access_code"` + CreatedAt string `json:"created_at"` +} + +// ClicdConfig is the main configuration structure +type ClicdConfig struct { + AdminUser string `json:"admin_user"` + AdminPassHash string `json:"admin_pass_hash"` + JWTSecret string `json:"jwt_secret"` + Port int `json:"port"` + DataDir string `json:"data_dir"` + Containers []Container `json:"containers"` + NextContainerID int `json:"next_container_id"` + NextVNCPort int `json:"next_vnc_port"` + NextSSHPort int `json:"next_ssh_port"` + SetupComplete bool `json:"setup_complete"` + Oversell OversellConfig `json:"oversell"` + SubUsers []SubUser `json:"sub_users"` + ApiKeys []ApiKeyConfig `json:"api_keys"` + AuditLogs []AuditLog `json:"audit_logs"` + Tasks []SavedTask `json:"tasks"` + LoginLogs []SavedLoginLog `json:"login_logs"` + EnabledImages []string `json:"enabled_images"` +} + +var configPath string +var AppConfig *ClicdConfig + +func getConfigPath() string { + if configPath != "" { + return configPath + } + home, err := os.UserHomeDir() + if err != nil { + home = "/root" + } + return filepath.Join(home, ".clicd", "config.json") +} + +func SetConfigPath(path string) { + configPath = path +} + +func getDataDir() string { + home, err := os.UserHomeDir() + if err != nil { + home = "/root" + } + return filepath.Join(home, ".clicd") +} + +func generateRandomString(length int) string { + b := make([]byte, length) + rand.Read(b) + return hex.EncodeToString(b)[:length] +} + +func generateUUIDString() string { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return generateRandomString(32) + } + b[6] = (b[6] & 0x0f) | 0x40 + b[8] = (b[8] & 0x3f) | 0x80 + return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]) +} + +// NewContainerUUID returns a UUID that is unique within the current config. +func NewContainerUUID() string { + for { + uuid := generateUUIDString() + if FindContainerByUUID(uuid) == nil { + return uuid + } + } +} + +// InitConfig initializes or loads the configuration +func InitConfig() (*ClicdConfig, error) { + cfgPath := getConfigPath() + dataDir := getDataDir() + + if err := os.MkdirAll(filepath.Dir(cfgPath), 0700); err != nil { + return nil, fmt.Errorf("failed to create config directory: %v", err) + } + if err := os.MkdirAll(dataDir, 0700); err != nil { + return nil, fmt.Errorf("failed to create data directory: %v", err) + } + + if _, err := os.Stat(cfgPath); os.IsNotExist(err) { + // First run: generate new config + adminUser := "admin" + adminPass := generateRandomString(16) + 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{}, + Oversell: OversellConfig{ + CPUOvercommit: 4, + RAMOvercommit: 1, + DiskOvercommit: 2, + KSMEnabled: true, + Swappiness: 10, + }, + } + + 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 + } + + // Load existing config + data, err := os.ReadFile(cfgPath) + if err != nil { + return nil, fmt.Errorf("failed to read config: %v", err) + } + + AppConfig = &ClicdConfig{} + if err := json.Unmarshal(data, AppConfig); err != nil { + return nil, fmt.Errorf("failed to parse config: %v", err) + } + + if AppConfig.Port == 0 { + AppConfig.Port = 8999 + } + if AppConfig.NextVNCPort == 0 { + AppConfig.NextVNCPort = 5900 + } + if AppConfig.NextSSHPort == 0 { + AppConfig.NextSSHPort = 22000 + } + if AppConfig.NextContainerID == 0 { + AppConfig.NextContainerID = 1 + } + if AppConfig.DataDir == "" { + AppConfig.DataDir = dataDir + } + if AppConfig.Containers == nil { + AppConfig.Containers = make([]Container, 0) + } + changed := ensureContainerUUIDs() + if ensureContainerPortMappingLimits() { + changed = true + } + if removeLegacyVNCMappings() { + changed = true + } + if changed { + if err := SaveConfig(); err != nil { + return nil, err + } + } + + return AppConfig, nil +} + +func ensureContainerUUIDs() bool { + changed := false + used := make(map[string]bool) + for i := range AppConfig.Containers { + uuid := AppConfig.Containers[i].UUID + if uuid == "" || used[uuid] { + for { + uuid = generateUUIDString() + if !used[uuid] { + break + } + } + AppConfig.Containers[i].UUID = uuid + changed = true + } + used[uuid] = true + } + return changed +} + +func ensureContainerPortMappingLimits() bool { + changed := false + for i := range AppConfig.Containers { + if AppConfig.Containers[i].PortMappingLimit <= 0 { + limit := len(AppConfig.Containers[i].PortMappings) + if limit < 2 { + limit = 2 + } + AppConfig.Containers[i].PortMappingLimit = limit + changed = true + } + } + return changed +} + +func removeLegacyVNCMappings() bool { + changed := false + for i := range AppConfig.Containers { + mappings := AppConfig.Containers[i].PortMappings + if len(mappings) == 0 { + continue + } + + filtered := mappings[:0] + for _, pm := range mappings { + isLegacyVNC := strings.EqualFold(pm.Description, "VNC") || pm.ContainerPort == 5901 + if isLegacyVNC { + changed = true + continue + } + filtered = append(filtered, pm) + } + AppConfig.Containers[i].PortMappings = filtered + } + return changed +} + +// SaveConfig saves configuration to disk +func SaveConfig() error { + data, err := json.MarshalIndent(AppConfig, "", " ") + 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 +func AddContainer(c Container) { + if c.UUID == "" { + c.UUID = NewContainerUUID() + } + AppConfig.Containers = append(AppConfig.Containers, c) + SaveConfig() +} + +// AllocateContainerID allocates a new container ID +func AllocateContainerID() int { + id := AppConfig.NextContainerID + AppConfig.NextContainerID++ + SaveConfig() + return id +} + +// RemoveContainer removes a container from config by ID +func RemoveContainer(id int) bool { + for i, c := range AppConfig.Containers { + if c.ID == id { + removeSubUserContainerAccess(c.Name) + AppConfig.Containers = append(AppConfig.Containers[:i], AppConfig.Containers[i+1:]...) + SaveConfig() + return true + } + } + return false +} + +func removeSubUserContainerAccess(containerName string) { + if containerName == "" || len(AppConfig.SubUsers) == 0 { + return + } + filteredUsers := make([]SubUser, 0, len(AppConfig.SubUsers)) + for _, su := range AppConfig.SubUsers { + filteredNames := make([]string, 0, len(su.ContainerNames)) + for _, name := range su.ContainerNames { + if name != containerName { + filteredNames = append(filteredNames, name) + } + } + if len(filteredNames) == 0 { + continue + } + su.ContainerNames = filteredNames + filteredUsers = append(filteredUsers, su) + } + AppConfig.SubUsers = filteredUsers +} + +// FindContainer finds a container by ID +func FindContainer(id int) *Container { + for i, c := range AppConfig.Containers { + if c.ID == id { + return &AppConfig.Containers[i] + } + } + return nil +} + +// FindContainerByUUID finds a container by UUID. +func FindContainerByUUID(uuid string) *Container { + for i, c := range AppConfig.Containers { + if c.UUID == uuid { + return &AppConfig.Containers[i] + } + } + return nil +} + +// FindContainerByName finds a container by name +func FindContainerByName(name string) *Container { + for i, c := range AppConfig.Containers { + if c.Name == name { + return &AppConfig.Containers[i] + } + } + return nil +} + +// FindContainerByIdentifier finds a container by ID, UUID, or name. +func FindContainerByIdentifier(identifier string) *Container { + if id, err := strconv.Atoi(identifier); err == nil { + if c := FindContainer(id); c != nil { + return c + } + } + if c := FindContainerByUUID(identifier); c != nil { + return c + } + return FindContainerByName(identifier) +} + +// UpdateContainerStatus updates container status by ID +func UpdateContainerStatus(id int, status string) { + c := FindContainer(id) + if c != nil { + c.Status = status + SaveConfig() + } +} + +// UpdateVNC refreshes all container statuses +func UpdateVNC(containers []Container) { + AppConfig.Containers = containers + SaveConfig() +} + +// AllocateSSHPort allocates a new SSH port +func AllocateSSHPort() int { + port := AppConfig.NextSSHPort + AppConfig.NextSSHPort++ + SaveConfig() + return port +} + +// IsValidContainerName checks if container name is valid (no duplicate check needed, ID is primary key) +func IsValidContainerName(name string) bool { + return IsValidContainerNameSyntax(name) +} + +// IsValidContainerNameSyntax checks only the container name format. +func IsValidContainerNameSyntax(name string) bool { + if len(name) == 0 || len(name) > 63 { + return false + } + // Only allow alphanumeric, hyphens, underscores + for _, c := range name { + if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_') { + return false + } + } + return true +} + +// AddAuditLog adds an audit log entry +func AddAuditLog(action, target, detail, user string) { + log := AuditLog{ + Time: time.Now().Format("2006-01-02 15:04:05"), + Action: action, + Target: target, + Detail: detail, + User: user, + } + AppConfig.AuditLogs = append(AppConfig.AuditLogs, log) + if len(AppConfig.AuditLogs) > 500 { + AppConfig.AuditLogs = AppConfig.AuditLogs[len(AppConfig.AuditLogs)-500:] + } + SaveConfig() +} + +// SaveTasks persists the task queue to config +func SaveTasks(tasks []SavedTask) { + AppConfig.Tasks = tasks + SaveConfig() +} + +// AddLoginLog persists a login log entry +func AddLoginLog(username, ip, userAgent string, success bool) { + log := SavedLoginLog{ + Time: time.Now().Format("2006-01-02 15:04:05 MST"), + Username: username, + IP: ip, + UserAgent: userAgent, + Success: success, + } + AppConfig.LoginLogs = append(AppConfig.LoginLogs, log) + if len(AppConfig.LoginLogs) > 200 { + AppConfig.LoginLogs = AppConfig.LoginLogs[len(AppConfig.LoginLogs)-200:] + } + SaveConfig() +} + +// ResetAdminPassword resets the admin password from CLI +func ResetAdminPassword(newPassword string) error { + hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost) + if err != nil { + return err + } + AppConfig.AdminPassHash = string(hash) + return SaveConfig() +} + +// CleanStaleContainers removes containers from config if their LXC directory doesn't exist +func CleanStaleContainers() { + valid := make([]Container, 0) + changed := false + for _, c := range AppConfig.Containers { + lxcDir := "/var/lib/lxc/" + c.LxcName() + if _, err := os.Stat(lxcDir); os.IsNotExist(err) { + fmt.Printf("Cleaning stale container config: %s (LXC dir not found)\n", c.LxcName()) + changed = true + continue + } + valid = append(valid, c) + } + if changed { + AppConfig.Containers = valid + SaveConfig() + } +} diff --git a/backend/internal/lxc/expiry.go b/backend/internal/lxc/expiry.go new file mode 100644 index 0000000..d1ecf1d --- /dev/null +++ b/backend/internal/lxc/expiry.go @@ -0,0 +1,137 @@ +package lxc + +import ( + "fmt" + "time" + + "clicd/internal/config" +) + +// IsExpired checks if a container has passed its expiration date +func IsExpired(c config.Container) bool { + return isContainerExpired(c, time.Now()) +} + +// StopExpiredContainers stops running containers whose expiration date has passed. +func (m *Manager) StopExpiredContainers(now time.Time) { + for _, container := range config.AppConfig.Containers { + if !isContainerExpired(container, now) { + continue + } + + status, err := m.GetContainerStatus(container.LxcName()) + if err != nil { + status = container.Status + } + if status != "running" { + continue + } + + fmt.Printf("Container %s (ID=%d) expired at %s, stopping...\n", container.Name, container.ID, container.ExpiresAt) + if err := m.StopContainer(container.ID); err != nil { + fmt.Printf("Warning: failed to stop expired container %s: %v\n", container.Name, err) + } + } +} + +// StartExpiryScanner runs a background loop that tracks traffic & stops expired/over-traffic containers every 30 seconds +func (m *Manager) StartExpiryScanner() { + go func() { + for { + time.Sleep(30 * time.Second) + now := time.Now() + m.AccumulateTraffic() // track network traffic deltas + m.StopExpiredContainers(now) + m.StopTrafficExceededContainers(now) + } + }() +} + +// StopTrafficExceededContainers stops running containers that have exceeded their monthly traffic limit +func (m *Manager) StopTrafficExceededContainers(now time.Time) { + currentMonth := now.Format("2006-01") + saved := false + for i := range config.AppConfig.Containers { + c := &config.AppConfig.Containers[i] + if c.Status != "running" { + continue + } + + // Reset traffic if new month + if c.TrafficResetDate != currentMonth { + c.TrafficUsedRX = 0 + c.TrafficUsedTX = 0 + c.TrafficResetDate = currentMonth + saved = true + continue + } + + // Check traffic limits + if isTrafficExceeded(*c) { + fmt.Printf("Container %s (ID=%d) exceeded traffic limit, stopping...\n", c.Name, c.ID) + if err := m.StopContainer(c.ID); err != nil { + fmt.Printf("Warning: failed to stop traffic-exceeded container %s: %v\n", c.Name, err) + } + } + } + if saved { + config.SaveConfig() + } +} + +func isTrafficExceeded(c config.Container) bool { + if c.TrafficMode == "in_out" { + inLimit := int64(c.TrafficInGB) * 1073741824 + outLimit := int64(c.TrafficOutGB) * 1073741824 + if inLimit > 0 && c.TrafficUsedRX >= inLimit { + return true + } + if outLimit > 0 && c.TrafficUsedTX >= outLimit { + return true + } + return false + } + totalLimit := int64(c.MonthlyTrafficGB) * 1073741824 + return totalLimit > 0 && (c.TrafficUsedRX+c.TrafficUsedTX) >= totalLimit +} + +// ResetTraffic resets traffic counters for a container +func (m *Manager) ResetTraffic(id int) error { + c := config.FindContainer(id) + if c == nil { + return fmt.Errorf("container not found: %d", id) + } + c.TrafficUsedRX = 0 + c.TrafficUsedTX = 0 + c.TrafficResetDate = time.Now().Format("2006-01") + config.SaveConfig() + return nil +} + +// IsTrafficExceeded checks if a container has exceeded its traffic limit +func IsTrafficExceeded(c config.Container) bool { + return isTrafficExceeded(c) +} + +func isContainerExpired(container config.Container, now time.Time) bool { + expiresAt, ok := ParseExpiration(container.ExpiresAt) + return ok && !now.Before(expiresAt) +} + +// ParseExpiration parses an expiration string. A YYYY-MM-DD value expires at the +// end of that local day, while RFC3339 values are treated as exact timestamps. +func ParseExpiration(value string) (time.Time, bool) { + if value == "" { + return time.Time{}, false + } + + if parsed, err := time.Parse(time.RFC3339, value); err == nil { + return parsed, true + } + + if parsed, err := time.ParseInLocation("2006-01-02", value, time.Local); err == nil { + return parsed.Add(24 * time.Hour), true + } + + return time.Time{}, false +} diff --git a/backend/internal/lxc/ipv6.go b/backend/internal/lxc/ipv6.go new file mode 100644 index 0000000..3fd9832 --- /dev/null +++ b/backend/internal/lxc/ipv6.go @@ -0,0 +1,553 @@ +package lxc + +import ( + "encoding/binary" + "fmt" + "math/big" + "net/netip" + "os" + "os/exec" + "path/filepath" + "sort" + "strconv" + "strings" + + "clicd/internal/config" +) + +const ipv6GatewayLinkLocal = "fe80::1" + +type IPv6PrefixInfo struct { + Interface string `json:"interface"` + Address string `json:"address"` + Prefix string `json:"prefix"` + PrefixLen int `json:"prefix_len"` + Gateway string `json:"gateway"` + IsTunnel bool `json:"is_tunnel"` + Source string `json:"source"` +} + +type PublicIPInfo struct { + Address string `json:"address"` + Interface string `json:"interface"` + Prefix string `json:"prefix"` + IsTunnel bool `json:"is_tunnel"` + Source string `json:"source"` +} + +type IPv6Status struct { + Available bool `json:"available"` + Reachable bool `json:"reachable"` + Reason string `json:"reason"` + Prefixes []IPv6PrefixInfo `json:"prefixes"` +} + +func (m *Manager) DetectIPv6Status() IPv6Status { + status := IPv6Status{} + prefixes := DetectPublicIPv6Prefixes() + status.Prefixes = prefixes + if len(prefixes) == 0 { + status.Reason = "no usable public IPv6 prefix found; /128 single-address IPv6 is not assignable" + return status + } + status.Reachable = ipv6ConnectivityOK() + if !status.Reachable { + status.Reason = "host has an IPv6 prefix, but outbound IPv6 connectivity test failed" + return status + } + status.Available = true + status.Reason = "usable public IPv6 prefix detected" + return status +} + +func DetectPublicIPv6Prefixes() []IPv6PrefixInfo { + return detectPublicIPv6Prefixes(detectIPv6DefaultRoutes()) +} + +func DetectPublicIPv4() PublicIPInfo { + candidates := DetectPublicIPv4Candidates() + if len(candidates) == 0 { + return PublicIPInfo{} + } + return candidates[0] +} + +func DetectPublicIPv4Candidates() []PublicIPInfo { + out, err := exec.Command("ip", "-4", "-o", "addr", "show", "scope", "global").Output() + if err != nil { + return nil + } + defaultRoutes := detectIPv4DefaultRoutes() + defaultIfaces := map[string]bool{} + for _, route := range defaultRoutes { + defaultIfaces[route.Interface] = true + } + type candidate struct { + info PublicIPInfo + score int + } + var candidates []candidate + seen := map[string]bool{} + for _, line := range strings.Split(string(out), "\n") { + fields := strings.Fields(line) + if len(fields) < 4 || fields[2] != "inet" { + continue + } + iface := normalizeIface(fields[1]) + if isContainerLikeInterface(iface) { + continue + } + prefix, err := netip.ParsePrefix(fields[3]) + if err != nil || !prefix.Addr().Is4() || !isPublicIPv4(prefix.Addr()) { + continue + } + key := iface + "|" + prefix.Addr().String() + if seen[key] { + continue + } + seen[key] = true + score := publicInterfaceScore(iface, defaultIfaces) + candidates = append(candidates, candidate{ + info: PublicIPInfo{ + Address: prefix.Addr().String(), + Interface: iface, + Prefix: prefix.Masked().String(), + IsTunnel: isTunnelLikeInterface(iface), + Source: "local", + }, + score: score, + }) + } + sort.SliceStable(candidates, func(i, j int) bool { + return candidates[i].score > candidates[j].score + }) + result := make([]PublicIPInfo, 0, len(candidates)) + for _, c := range candidates { + result = append(result, c.info) + } + return result +} + +func detectPublicIPv6Prefixes(defaultRoutes []routeInfo) []IPv6PrefixInfo { + out, err := exec.Command("ip", "-6", "-o", "addr", "show", "scope", "global").Output() + if err != nil { + return nil + } + defaultIfaces := map[string]bool{} + gateways := map[string]string{} + for _, route := range defaultRoutes { + defaultIfaces[route.Interface] = true + if route.Gateway != "" && gateways[route.Interface] == "" { + gateways[route.Interface] = route.Gateway + } + } + type candidate struct { + info IPv6PrefixInfo + score int + } + var candidates []candidate + seen := map[string]bool{} + for _, line := range strings.Split(string(out), "\n") { + fields := strings.Fields(line) + if len(fields) < 4 || fields[2] != "inet6" { + continue + } + iface := normalizeIface(fields[1]) + if isContainerLikeInterface(iface) { + continue + } + prefix, err := netip.ParsePrefix(fields[3]) + if err != nil || !prefix.Addr().Is6() { + continue + } + addr := prefix.Addr() + if !isPublicIPv6(addr) { + continue + } + // Require at least 8 host bits. /128 is a single address, not a usable segment. + if prefix.Bits() > 120 { + continue + } + masked := prefix.Masked() + key := iface + "|" + masked.String() + if seen[key] { + continue + } + seen[key] = true + score := publicInterfaceScore(iface, defaultIfaces) + if masked.Bits() <= 64 { + score += 20 + } + info := IPv6PrefixInfo{ + Interface: iface, + Address: addr.String(), + Prefix: masked.String(), + PrefixLen: masked.Bits(), + Gateway: gateways[iface], + IsTunnel: isTunnelLikeInterface(iface), + Source: "local", + } + candidates = append(candidates, candidate{info: info, score: score}) + } + sort.SliceStable(candidates, func(i, j int) bool { + return candidates[i].score > candidates[j].score + }) + result := make([]IPv6PrefixInfo, 0, len(candidates)) + for _, c := range candidates { + result = append(result, c.info) + } + return result +} + +type routeInfo struct { + Interface string + Gateway string + Metric int +} + +func detectIPv4DefaultRoutes() []routeInfo { + out, err := exec.Command("ip", "-4", "route", "show", "default").Output() + if err != nil { + return nil + } + return parseDefaultRoutes(string(out)) +} + +func detectIPv6DefaultRoutes() []routeInfo { + out, err := exec.Command("ip", "-6", "route", "show", "default").Output() + if err != nil { + return nil + } + return parseDefaultRoutes(string(out)) +} + +func parseDefaultRoutes(output string) []routeInfo { + var routes []routeInfo + for _, line := range strings.Split(output, "\n") { + fields := strings.Fields(line) + if len(fields) == 0 || fields[0] != "default" { + continue + } + route := routeInfo{Metric: 1024} + for i := 1; i < len(fields)-1; i++ { + switch fields[i] { + case "dev": + route.Interface = normalizeIface(fields[i+1]) + case "via": + route.Gateway = fields[i+1] + case "metric": + metric, err := strconv.Atoi(fields[i+1]) + if err == nil { + route.Metric = metric + } + } + } + if route.Interface != "" { + routes = append(routes, route) + } + } + sort.SliceStable(routes, func(i, j int) bool { + return routes[i].Metric < routes[j].Metric + }) + return routes +} + +func ipv6ConnectivityOK() bool { + targets := [][]string{ + {"ping", "-6", "-c", "1", "-W", "2", "2606:4700:4700::1111"}, + {"ping", "-6", "-c", "1", "-W", "2", "2001:4860:4860::8888"}, + {"ping6", "-c", "1", "-W", "2", "2606:4700:4700::1111"}, + } + for _, args := range targets { + if exec.Command(args[0], args[1:]...).Run() == nil { + return true + } + } + return false +} + +func isContainerLikeInterface(iface string) bool { + prefixes := []string{ + "lo", "lxc", "docker", "br-", "veth", "virbr", "cni", "flannel", "cali", + "kube", "dummy", "ifb", "zt", "zerotier", + } + for _, prefix := range prefixes { + if iface == prefix || strings.HasPrefix(iface, prefix) { + return true + } + } + return false +} + +func normalizeIface(iface string) string { + iface = strings.TrimSuffix(iface, ":") + if at := strings.Index(iface, "@"); at >= 0 { + iface = iface[:at] + } + return iface +} + +func publicInterfaceScore(iface string, defaultIfaces map[string]bool) int { + score := 0 + if defaultIfaces[iface] { + score += 100 + } + if isTunnelLikeInterface(iface) { + score -= 120 + } else { + score += 80 + } + if isLikelyPhysicalInterface(iface) { + score += 40 + } + if operState(iface) == "up" { + score += 10 + } + return score +} + +func isLikelyPhysicalInterface(iface string) bool { + prefixes := []string{"eth", "ens", "eno", "enp", "em", "bond", "team"} + for _, prefix := range prefixes { + if strings.HasPrefix(iface, prefix) { + return true + } + } + return false +} + +func isTunnelLikeInterface(iface string) bool { + lower := strings.ToLower(iface) + prefixes := []string{ + "wg", "wgcf", "warp", "cloudflare", "tun", "tap", "tailscale", "ts", + "vpn", "ppp", "ipsec", "gre", "gretap", "sit", "he-", "nebula", "zt", + } + for _, prefix := range prefixes { + if lower == prefix || strings.HasPrefix(lower, prefix) { + return true + } + } + return strings.Contains(lower, "warp") || strings.Contains(lower, "cloudflare") +} + +func operState(iface string) string { + data, err := os.ReadFile("/sys/class/net/" + iface + "/operstate") + if err != nil { + return "" + } + return strings.TrimSpace(string(data)) +} + +func isPublicIPv4(addr netip.Addr) bool { + if !addr.IsGlobalUnicast() || addr.IsPrivate() || addr.IsLoopback() || addr.IsLinkLocalUnicast() { + return false + } + raw := addr.As4() + if raw[0] == 100 && raw[1] >= 64 && raw[1] <= 127 { + return false + } + if raw[0] == 192 && raw[1] == 0 && raw[2] == 0 { + return false + } + return true +} + +func isPublicIPv6(addr netip.Addr) bool { + if !addr.IsGlobalUnicast() || addr.IsPrivate() || addr.IsLoopback() || addr.IsLinkLocalUnicast() { + return false + } + return !strings.HasPrefix(addr.String(), "2001:db8:") +} + +func (m *Manager) allocateIPv6ForContainer(id int) (string, int, string, error) { + status := m.DetectIPv6Status() + if !status.Available { + return "", 0, "", fmt.Errorf("public IPv6 allocation is unavailable: %s", status.Reason) + } + prefixInfo := status.Prefixes[0] + prefix, err := netip.ParsePrefix(prefixInfo.Prefix) + if err != nil { + return "", 0, "", err + } + + used := map[string]bool{} + hostAddrs := map[string]bool{} + for _, p := range status.Prefixes { + hostAddrs[p.Address] = true + } + for _, c := range config.AppConfig.Containers { + if c.IPv6 != "" { + used[c.IPv6] = true + } + } + for offset := uint64(0x1000 + id); offset < 0x100000; offset++ { + addr, err := ipv6Add(prefix.Masked().Addr(), offset) + if err != nil || !prefix.Contains(addr) { + break + } + candidate := addr.String() + if !used[candidate] && !hostAddrs[candidate] { + return candidate, prefix.Bits(), prefixInfo.Interface, nil + } + } + return "", 0, "", fmt.Errorf("no free IPv6 address in %s", prefix.String()) +} + +func ipv6Add(base netip.Addr, offset uint64) (netip.Addr, error) { + raw := base.As16() + value := big.NewInt(0).SetBytes(raw[:]) + add := make([]byte, 8) + binary.BigEndian.PutUint64(add, offset) + value.Add(value, big.NewInt(0).SetBytes(add)) + bytes := value.Bytes() + if len(bytes) > 16 { + return netip.Addr{}, fmt.Errorf("IPv6 address overflow") + } + padded := make([]byte, 16) + copy(padded[16-len(bytes):], bytes) + var out [16]byte + copy(out[:], padded) + return netip.AddrFrom16(out), nil +} + +func (m *Manager) AssignIPv6(id int) (*config.Container, error) { + c := config.FindContainer(id) + if c == nil { + return nil, fmt.Errorf("container not found: %d", id) + } + if c.IPv6 == "" { + addr, prefixLen, iface, err := m.allocateIPv6ForContainer(id) + if err != nil { + return nil, err + } + c.IPv6 = addr + c.IPv6PrefixLen = prefixLen + c.IPv6Interface = iface + config.SaveConfig() + } + if err := m.applyIPv6Config(c.LxcName(), c.IPv6); err != nil { + return nil, err + } + if err := m.ApplyIPv6(id); err != nil { + return nil, err + } + return c, nil +} + +func (m *Manager) applyIPv6Config(lxcName, ipv6 string) error { + configFile := filepath.Join(m.LxcPath, lxcName, "config") + data, err := os.ReadFile(configFile) + if err != nil { + return fmt.Errorf("failed to read container config: %v", err) + } + lines := strings.Split(string(data), "\n") + next := make([]string, 0, len(lines)+4) + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if strings.Contains(trimmed, "# clicd managed: public IPv6") || + strings.HasPrefix(trimmed, "lxc.net.0.ipv6.address") || + strings.HasPrefix(trimmed, "lxc.net.0.ipv6.gateway") { + continue + } + next = append(next, line) + } + if ipv6 != "" { + next = append(next, "", "# clicd managed: public IPv6 routed /128") + next = append(next, fmt.Sprintf("lxc.net.0.ipv6.address = %s/128", ipv6)) + next = append(next, "lxc.net.0.ipv6.gateway = auto") + } + return os.WriteFile(configFile, []byte(strings.Join(next, "\n")), 0644) +} + +func (m *Manager) ApplyIPv6(id int) error { + c := config.FindContainer(id) + if c == nil { + return fmt.Errorf("container not found: %d", id) + } + if c.IPv6 == "" { + return nil + } + if c.IPv6Interface == "" { + status := m.DetectIPv6Status() + if len(status.Prefixes) == 0 { + return fmt.Errorf("failed to detect IPv6 uplink for %s", c.IPv6) + } + c.IPv6Interface = status.Prefixes[0].Interface + c.IPv6PrefixLen = status.Prefixes[0].PrefixLen + config.SaveConfig() + } + + if err := ensureHostIPv6Routing(c.IPv6, c.IPv6Interface); err != nil { + return err + } + status, _ := m.GetContainerStatus(c.LxcName()) + if status != "running" { + return nil + } + cmd := exec.Command("lxc-attach", "-n", c.LxcName(), "--", "sh", "-c", + fmt.Sprintf("ip -6 addr replace %s/128 dev eth0 && ip -6 route replace default via %s dev eth0", + shellQuote(c.IPv6), shellQuote(ipv6GatewayLinkLocal))) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("failed to apply IPv6 inside container: %v, output: %s", err, string(output)) + } + return nil +} + +func ensureHostIPv6Routing(ipv6, uplink string) error { + if uplink == "" { + return fmt.Errorf("missing IPv6 uplink interface") + } + runQuiet("sysctl", "-w", "net.ipv6.conf.all.forwarding=1") + runQuiet("sysctl", "-w", "net.ipv6.conf."+uplink+".accept_ra=2") + runQuiet("sysctl", "-w", "net.ipv6.conf."+uplink+".proxy_ndp=1") + runQuiet("ip", "link", "set", "lxcbr0", "up") + runQuiet("ip", "-6", "addr", "add", ipv6GatewayLinkLocal+"/64", "dev", "lxcbr0") + if out, err := exec.Command("ip", "-6", "route", "replace", ipv6+"/128", "dev", "lxcbr0").CombinedOutput(); err != nil { + return fmt.Errorf("failed to add IPv6 host route: %v, output: %s", err, string(out)) + } + if out, err := exec.Command("ip", "-6", "neigh", "replace", "proxy", ipv6, "dev", uplink).CombinedOutput(); err != nil { + return fmt.Errorf("failed to add IPv6 proxy NDP: %v, output: %s", err, string(out)) + } + ensureIPv6ForwardRules(ipv6) + return nil +} + +func ensureIPv6ForwardRules(ipv6 string) { + rules := [][]string{ + {"FORWARD", "-i", "lxcbr0", "-s", ipv6 + "/128", "-j", "ACCEPT"}, + {"FORWARD", "-o", "lxcbr0", "-d", ipv6 + "/128", "-j", "ACCEPT"}, + } + for _, rule := range rules { + check := append([]string{"-C"}, rule...) + add := append([]string{"-A"}, rule...) + if exec.Command("ip6tables", check...).Run() != nil { + exec.Command("ip6tables", add...).Run() + } + } +} + +func runQuiet(name string, args ...string) { + _ = exec.Command(name, args...).Run() +} + +func (m *Manager) AssignedIPv6Count() int { + count := 0 + for _, c := range config.AppConfig.Containers { + if strings.TrimSpace(c.IPv6) != "" { + count++ + } + } + return count +} + +func IPv6PrefixCapacity(prefixLen int) string { + if prefixLen <= 0 || prefixLen > 128 { + return "0" + } + hostBits := 128 - prefixLen + if hostBits > 32 { + return "large" + } + return strconv.FormatUint(uint64(1)</dev/null || "+ + "cat /sys/fs/cgroup/lxc.payload.%[1]s/memory.current 2>/dev/null || "+ + "cat /sys/fs/cgroup/memory/lxc/%[1]s/memory.usage_in_bytes 2>/dev/null || echo 0", shellQuote(lxcName))) + + cpuUsec := uint64(readIntCommand(fmt.Sprintf( + "(cat /sys/fs/cgroup/lxc/%[1]s/cpu.stat 2>/dev/null || "+ + "cat /sys/fs/cgroup/lxc.payload.%[1]s/cpu.stat 2>/dev/null) | "+ + "awk '/usage_usec/ {print $2; found=1} END {if (!found) print 0}'", shellQuote(lxcName)))) + + rxBytes, txBytes := m.getContainerNetworkBytes(lxcName) + readBytes, writeBytes := m.getContainerDiskIOBytes(lxcName) + + now := time.Now() + sample := containerUsageSample{ + CPUUsec: cpuUsec, + RXBytes: rxBytes, + TXBytes: txBytes, + ReadBytes: readBytes, + WriteBytes: writeBytes, + At: now, + } + + prev, exists := lastUsage[lxcName] + lastUsage[lxcName] = sample + + rate := containerRateSnapshot{UpdatedAt: now} + if exists { + elapsed := sample.At.Sub(prev.At).Seconds() + if elapsed > 0 && sample.CPUUsec >= prev.CPUUsec { + rate.CPUPct = float64(sample.CPUUsec-prev.CPUUsec) / (elapsed * 1e6) * 100 + } + if elapsed > 0 { + if sample.RXBytes >= prev.RXBytes { + rate.RXBps = float64(sample.RXBytes-prev.RXBytes) / elapsed + } + if sample.TXBytes >= prev.TXBytes { + rate.TXBps = float64(sample.TXBytes-prev.TXBytes) / elapsed + } + if sample.ReadBytes >= prev.ReadBytes { + rate.ReadBps = float64(sample.ReadBytes-prev.ReadBytes) / elapsed + } + if sample.WriteBytes >= prev.WriteBytes { + rate.WriteBps = float64(sample.WriteBytes-prev.WriteBytes) / elapsed + } + } + } else { + // First sample: estimate from container uptime + uptimeSec := m.getContainerUptimeSeconds(lxcName) + if uptimeSec > 0 { + rate.CPUPct = (float64(cpuUsec) / 1e6) / uptimeSec * 100 + rate.RXBps = float64(rxBytes) / uptimeSec + rate.TXBps = float64(txBytes) / uptimeSec + rate.ReadBps = float64(readBytes) / uptimeSec + rate.WriteBps = float64(writeBytes) / uptimeSec + } + } + + // Fallback: if delta rate is 0 but cumulative has data, use cumulative average + if rate.RXBps == 0 && rateCache[lxcName].RXBps > 0 { + rate.RXBps = rateCache[lxcName].RXBps + } + if rate.TXBps == 0 && rateCache[lxcName].TXBps > 0 { + rate.TXBps = rateCache[lxcName].TXBps + } + + // Store memory as a rate field for convenience + _ = memUsage + rateCache[lxcName] = rate + } + + // Clean up stale entries + for name := range rateCache { + found := false + for i := range config.AppConfig.Containers { + if config.AppConfig.Containers[i].LxcName() == name && config.AppConfig.Containers[i].Status == "running" { + found = true + break + } + } + if !found { + delete(rateCache, name) + delete(lastUsage, name) + } + } +} + +// NewManager creates a new LXC manager +func NewManager() *Manager { + return &Manager{ + LxcPath: "/var/lib/lxc", + } +} + +// ContainerConfig defines container creation parameters +type ContainerConfig struct { + Name string `json:"name"` + 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"` // "total" or "in_out" + TrafficInGB int `json:"traffic_in_gb"` // 0=unlimited + TrafficOutGB int `json:"traffic_out_gb"` // 0=unlimited + IOSpeedMBps int `json:"io_speed_mbps"` + ExtraPorts []int `json:"extra_ports"` + PortMappingCount int `json:"port_mapping_count"` + AssignIPv6 bool `json:"assign_ipv6"` + ExpiresAt string `json:"expires_at"` +} + +// CreateContainer creates a new LXC container. Uses ct-{id} as LXC name internally. +func (m *Manager) CreateContainer(cfg ContainerConfig) error { + tmpl := FindTemplate(cfg.TemplateID) + if tmpl == nil { + return fmt.Errorf("template not found: %s", cfg.TemplateID) + } + + if !config.IsValidContainerName(cfg.Name) { + return fmt.Errorf("invalid container name: %s", cfg.Name) + } + if config.FindContainerByName(cfg.Name) != nil { + return fmt.Errorf("container name already exists: %s", cfg.Name) + } + + // Allocate ID and build LXC name + id := config.AllocateContainerID() + lxcName := fmt.Sprintf("ct-%d", id) + + containerDir := filepath.Join(m.LxcPath, lxcName) + if _, err := os.Stat(containerDir); err == nil { + if err := m.cleanupContainerStorage(lxcName); err != nil { + return fmt.Errorf("failed to clean stale container directory %s: %v", lxcName, err) + } + } else if !os.IsNotExist(err) { + return fmt.Errorf("failed to check container directory %s: %v", lxcName, err) + } + + fmt.Printf("Creating LXC container: %s (ID=%d, template: %s/%s/%s)\n", + lxcName, id, tmpl.Distro, tmpl.Release, tmpl.Arch) + + args := []string{"-n", lxcName, "-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() + if err != nil { + return fmt.Errorf("lxc-create failed: %v, output: %s", err, string(output)) + } + + if err := m.applyDiskLimit(lxcName, cfg.DiskGB); err != nil { + _ = m.cleanupContainerStorage(lxcName) + return err + } + + // Apply resource limits and mandatory security hardening. + if err := m.applyResourceLimits(lxcName, cfg); err != nil { + _ = m.cleanupContainerStorage(lxcName) + return err + } + + ipv6 := "" + ipv6PrefixLen := 0 + ipv6Interface := "" + if cfg.AssignIPv6 { + assigned, prefixLen, iface, err := m.allocateIPv6ForContainer(id) + if err != nil { + _ = m.cleanupContainerStorage(lxcName) + return err + } + ipv6 = assigned + ipv6PrefixLen = prefixLen + ipv6Interface = iface + if err := m.applyIPv6Config(lxcName, ipv6); err != nil { + _ = m.cleanupContainerStorage(lxcName) + return err + } + } + + sshPort := config.AllocateSSHPort() + sshPassword := generateRandomString(16) + + // Setup default port mappings (SSH only) + portMappings := SetupDefaultPortMappings(sshPort) + tempC := &config.Container{PortMappings: portMappings} + + extraPorts := cfg.ExtraPorts + if len(extraPorts) == 0 && cfg.PortMappingCount > 1 { + extraPorts = allocateDefaultEqualPorts(tempC, cfg.PortMappingCount-1) + } + for _, containerPort := range extraPorts { + if containerPort <= 0 { + continue + } + pm, err := normalizePortMapping(tempC, -1, config.PortMapping{ + ContainerPort: containerPort, + HostPort: containerPort, + Protocol: "tcp", + Description: fmt.Sprintf("Port-%d", containerPort), + }) + if err != nil { + continue + } + tempC.PortMappings = append(tempC.PortMappings, pm) + portMappings = tempC.PortMappings + } + + now := time.Now().Format("2006-01-02 15:04:05") + // Determine traffic mode + trafficMode := cfg.TrafficMode + if trafficMode == "" { + trafficMode = "total" + } + trafficResetDate := now[:7] // YYYY-MM for monthly tracking + + container := config.Container{ + ID: id, + UUID: config.NewContainerUUID(), + Name: cfg.Name, + Template: cfg.TemplateID, + VCPU: cfg.VCPU, + RAMMB: cfg.RAMMB, + DiskGB: cfg.DiskGB, + NetworkBWMbps: cfg.NetworkBWMbps, + MonthlyTrafficGB: cfg.MonthlyTrafficGB, + TrafficMode: trafficMode, + TrafficInGB: cfg.TrafficInGB, + TrafficOutGB: cfg.TrafficOutGB, + TrafficResetDate: trafficResetDate, + IOSpeedMBps: cfg.IOSpeedMBps, + Status: "stopped", + IP: "", + IPv6: ipv6, + IPv6PrefixLen: ipv6PrefixLen, + IPv6Interface: ipv6Interface, + VNCPort: 0, + SSHPort: sshPort, + SSHPassword: sshPassword, + PortMappings: portMappings, + PortMappingLimit: cfg.PortMappingCount, + CreatedAt: now, + ExpiresAt: cfg.ExpiresAt, + } + config.AddContainer(container) + + // Pre-configure network and SSH in the rootfs before first boot. + rootfsPath := filepath.Join(m.LxcPath, lxcName, "rootfs") + m.preconfigureNetwork(rootfsPath, cfg.TemplateID) + if err := m.preconfigureSSH(rootfsPath, sshPassword, cfg.TemplateID); err != nil { + fmt.Printf("Warning: failed to pre-configure SSH in %s: %v\n", lxcName, err) + } + + if err := m.shiftRootfsForUnprivileged(lxcName); err != nil { + _ = m.cleanupContainerStorage(lxcName) + return err + } + + // Set root password AFTER shiftRootfsForUnprivileged, + // otherwise /etc/shadow ownership breaks and SSHD cannot authenticate. + setCmd := m.rootfsCommand(rootfsPath, + "sh", "-c", fmt.Sprintf("printf '%%s:%%s\\n' root %s | chpasswd", shellQuote(sshPassword))) + setCmd.Run() + + fmt.Printf("Container %d (%s) created successfully\n", id, cfg.Name) + return nil +} + +func (m *Manager) preconfigureNetwork(rootfsPath, templateID string) { + osRelease := "" + if data, err := os.ReadFile(filepath.Join(rootfsPath, "etc", "os-release")); err == nil { + osRelease = strings.ToLower(string(data)) + } + isAlpine := strings.Contains(osRelease, "alpine") + isRHELFamily := strings.Contains(osRelease, "centos") || + strings.Contains(osRelease, "rhel") || + strings.Contains(osRelease, "rocky") || + strings.Contains(osRelease, "alma") || + strings.Contains(osRelease, "fedora") || + strings.Contains(templateID, "fedora") || + strings.Contains(templateID, "rockylinux") + + if isAlpine { + interfaces := filepath.Join(rootfsPath, "etc", "network", "interfaces") + content := "auto lo\niface lo inet loopback\n\nauto eth0\niface eth0 inet dhcp\n" + _ = os.MkdirAll(filepath.Dir(interfaces), 0755) + _ = os.WriteFile(interfaces, []byte(content), 0644) + _ = exec.Command("chroot", rootfsPath, "rc-update", "add", "networking", "boot").Run() + return + } + + if isRHELFamily || strings.Contains(templateID, "centos") { + nmDir := filepath.Join(rootfsPath, "etc", "NetworkManager", "system-connections") + if err := os.MkdirAll(nmDir, 0700); err == nil { + keyfile := `[connection] +id=eth0 +type=ethernet +interface-name=eth0 +autoconnect=true + +[ipv4] +method=auto + +[ipv6] +method=ignore +` + path := filepath.Join(nmDir, "eth0.nmconnection") + _ = os.WriteFile(path, []byte(keyfile), 0600) + } + _ = exec.Command("chroot", rootfsPath, "systemctl", "enable", "NetworkManager").Run() + } + + networkdDir := filepath.Join(rootfsPath, "etc", "systemd", "network") + if err := os.MkdirAll(networkdDir, 0755); err == nil { + network := `[Match] +Name=eth0 + +[Network] +DHCP=ipv4 +IPv6AcceptRA=no +` + _ = os.WriteFile(filepath.Join(networkdDir, "10-eth0.network"), []byte(network), 0644) + } + if !isRHELFamily { + _ = exec.Command("chroot", rootfsPath, "systemctl", "enable", "systemd-networkd").Run() + } +} + +// preconfigureSSH installs and configures SSH directly in the rootfs before first boot. +func (m *Manager) preconfigureSSH(rootfsPath, password, templateID string) error { + _ = templateID + ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) + defer cancel() + cmd := m.rootfsCommand(rootfsPath, "sh", "-c", sshSetupScript(password, false)) + cmd = exec.CommandContext(ctx, cmd.Path, cmd.Args[1:]...) + output, err := cmd.CombinedOutput() + if ctx.Err() == context.DeadlineExceeded { + return fmt.Errorf("timed out after 120s, output: %s", string(output)) + } + if err != nil { + return fmt.Errorf("%v, output: %s", err, string(output)) + } + fmt.Printf("SSH pre-configured in rootfs\n") + return nil +} + +// applyResourceLimits applies cgroup v2 limits and mandatory security hardening to container config. +func (m *Manager) applyResourceLimits(lxcName string, cfg ContainerConfig) error { + configFile := filepath.Join(m.LxcPath, lxcName, "config") + + data, err := os.ReadFile(configFile) + if err != nil { + return fmt.Errorf("failed to read container config: %v", err) + } + content := string(data) + + lines := strings.Split(content, "\n") + var newLines []string + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if !strings.Contains(trimmed, "# clicd managed") && + !strings.HasPrefix(trimmed, "lxc.cgroup2.memory.max") && + !strings.HasPrefix(trimmed, "lxc.cgroup2.cpuset.cpus") && + !strings.HasPrefix(trimmed, "lxc.cgroup2.cpu.max") && + !strings.HasPrefix(trimmed, "lxc.cgroup2.io.max") && + !strings.HasPrefix(trimmed, "lxc.mount.auto") && + !strings.HasPrefix(trimmed, "lxc.prlimit") && + !strings.HasPrefix(trimmed, "lxc.idmap") && + !strings.HasPrefix(trimmed, "lxc.apparmor.profile") && + !strings.HasPrefix(trimmed, "lxc.seccomp.profile") && + !strings.HasPrefix(trimmed, "lxc.no_new_privs") && + !strings.HasPrefix(trimmed, "lxc.cap.drop") { + newLines = append(newLines, line) + } + } + + seccompProfile, err := findSeccompProfile() + if err != nil { + return err + } + apparmorProfile, err := findAppArmorProfile() + if err != nil { + return err + } + uidBase, gidBase, err := unprivilegedIDMap() + if err != nil { + return err + } + + newLines = append(newLines, "", "# clicd managed: lxcfs virtualized /proc") + newLines = append(newLines, "lxc.mount.auto = proc:mixed sys:mixed cgroup:mixed") + newLines = append(newLines, "", "# clicd managed: mandatory unprivileged container hardening") + newLines = append(newLines, fmt.Sprintf("lxc.idmap = u 0 %d 65536", uidBase)) + newLines = append(newLines, fmt.Sprintf("lxc.idmap = g 0 %d 65536", gidBase)) + newLines = append(newLines, fmt.Sprintf("lxc.apparmor.profile = %s", apparmorProfile)) + newLines = append(newLines, fmt.Sprintf("lxc.seccomp.profile = %s", seccompProfile)) + newLines = append(newLines, "lxc.no_new_privs = 1") + // Keep sys_admin: unprivileged containers need it to mount tmpfs (/dev/shm, /run, etc.) + // All capabilities are already confined to the container's user namespace. + newLines = append(newLines, "lxc.cap.drop = mac_admin mac_override sys_module sys_rawio sys_time sys_boot sys_nice sys_resource sys_ptrace sys_pacct mknod audit_control audit_read") + newLines = append(newLines, "lxc.prlimit.nofile = 1024:4096") + newLines = append(newLines, "lxc.prlimit.nproc = 128:256") + newLines = append(newLines, "", "# clicd managed resource limits (cgroup v2)") + + if cfg.VCPU > 0 { + cpuPct := cfg.CPUPercent + if cpuPct <= 0 || cpuPct > 100 { + cpuPct = 100 + } + cpuQuota := int(cfg.VCPU * float64(cpuPct) / 100.0 * 100000) + newLines = append(newLines, fmt.Sprintf("lxc.cgroup2.cpu.max = %d 100000", cpuQuota)) + } + if cfg.RAMMB > 0 { + ramBytes := int64(cfg.RAMMB) * 1024 * 1024 + newLines = append(newLines, fmt.Sprintf("lxc.cgroup2.memory.max = %d", ramBytes)) + } + if cfg.IOSpeedMBps > 0 { + // Note: lxc.cgroup2.io.max is skipped for unprivileged containers because + // LXC's cgfsng_setup_limits cannot resolve host device numbers (e.g. 8:1) + // in the unprivileged namespace context. + // IO limits are instead applied post-start via direct cgroup2 writes. + fmt.Printf("Info: IO limit (%d MB/s) for %s will be applied post-start via cgroup2\n", cfg.IOSpeedMBps, lxcName) + } + + newContent := strings.Join(newLines, "\n") + if err := os.WriteFile(configFile, []byte(newContent), 0644); err != nil { + return fmt.Errorf("failed to write container config: %v", err) + } + return nil +} + +func (m *Manager) ioLimitLines(lxcName string, mbps int) ([]string, error) { + if mbps <= 0 { + return nil, nil + } + devices, err := m.rootfsBlockDevices(lxcName) + if err != nil { + return nil, err + } + ioBytes := mbps * 1024 * 1024 + lines := make([]string, 0, len(devices)) + for _, device := range devices { + lines = append(lines, fmt.Sprintf("%s rbps=%d wbps=%d", device, ioBytes, ioBytes)) + } + return lines, nil +} + +func (m *Manager) rootfsBlockDevices(lxcName string) ([]string, error) { + rootfsPath := filepath.Join(m.LxcPath, lxcName, "rootfs") + out, err := exec.Command("findmnt", "-T", rootfsPath, "-no", "MAJ:MIN").Output() + if err != nil { + return nil, fmt.Errorf("failed to detect rootfs block device for IO limit: %v", err) + } + re := regexp.MustCompile(`\b\d+:\d+\b`) + matches := re.FindAllString(string(out), -1) + seen := map[string]bool{} + devices := make([]string, 0, len(matches)) + for _, match := range matches { + if match == "0:0" || seen[match] { + continue + } + seen[match] = true + devices = append(devices, match) + } + if len(devices) == 0 { + return nil, fmt.Errorf("failed to detect rootfs block device for IO limit; refusing to use hardcoded 8:0") + } + return devices, nil +} + +func (m *Manager) applyDiskLimit(lxcName string, diskGB int) error { + if diskGB <= 0 { + return nil + } + return m.applyLoopbackDiskLimit(lxcName, diskGB) +} + +func (m *Manager) applyProjectDiskLimit(lxcName string, diskGB int) error { + if diskGB <= 0 { + return nil + } + rootfsPath := filepath.Join(m.LxcPath, lxcName, "rootfs") + if diskImageMounted(lxcName, rootfsPath) { + return nil + } + fsType, err := findmntValue(rootfsPath, "FSTYPE") + if err != nil { + return fmt.Errorf("failed to detect rootfs filesystem for disk quota: %v", err) + } + fsType = strings.TrimSpace(fsType) + + switch fsType { + case "btrfs": + if err := exec.Command("btrfs", "quota", "enable", rootfsPath).Run(); err != nil { + // btrfs returns an error when quota is already enabled on some versions. + fmt.Printf("Warning: btrfs quota enable returned: %v\n", err) + } + output, err := exec.Command("btrfs", "qgroup", "limit", fmt.Sprintf("%dG", diskGB), rootfsPath).CombinedOutput() + if err != nil { + fmt.Printf("Warning: failed to apply btrfs disk quota, falling back to loopback rootfs: %v, output: %s\n", err, string(output)) + return m.applyLoopbackDiskLimit(lxcName, diskGB) + } + return nil + case "xfs": + if err := applyXFSProjectQuota(rootfsPath, lxcName, diskGB); err != nil { + fmt.Printf("Warning: xfs project quota unavailable, falling back to loopback rootfs: %v\n", err) + return m.applyLoopbackDiskLimit(lxcName, diskGB) + } + return nil + case "ext4": + if err := applyExt4ProjectQuota(rootfsPath, lxcName, diskGB); err != nil { + fmt.Printf("Warning: ext4 project quota unavailable, falling back to loopback rootfs: %v\n", err) + return m.applyLoopbackDiskLimit(lxcName, diskGB) + } + return nil + default: + fmt.Printf("Warning: unsupported rootfs filesystem %q for project quota, falling back to loopback rootfs\n", fsType) + return m.applyLoopbackDiskLimit(lxcName, diskGB) + } +} + +func (m *Manager) applyLoopbackDiskLimit(lxcName string, diskGB int) error { + containerDir := filepath.Join(m.LxcPath, lxcName) + rootfsPath := filepath.Join(containerDir, "rootfs") + imagePath := filepath.Join(containerDir, "rootfs.img") + if diskImageMounted(lxcName, rootfsPath) { + return nil + } + if _, err := os.Stat(imagePath); err == nil { + return m.ensureDiskImageMounted(lxcName) + } + + tmpMount := filepath.Join(containerDir, ".rootfs-image") + backupRootfs := filepath.Join(containerDir, "rootfs.dir") + if err := os.MkdirAll(tmpMount, 0755); err != nil { + return err + } + defer os.RemoveAll(tmpMount) + + output, err := exec.Command("truncate", "-s", fmt.Sprintf("%dG", diskGB), imagePath).CombinedOutput() + if err != nil { + return fmt.Errorf("failed to create rootfs disk image: %v, output: %s", err, string(output)) + } + output, err = exec.Command("mkfs.ext4", "-F", imagePath).CombinedOutput() + if err != nil { + return fmt.Errorf("failed to format rootfs disk image: %v, output: %s", err, string(output)) + } + output, err = exec.Command("mount", "-o", "loop", imagePath, tmpMount).CombinedOutput() + if err != nil { + return fmt.Errorf("failed to mount rootfs disk image: %v, output: %s", err, string(output)) + } + mountedTmp := true + defer func() { + if mountedTmp { + exec.Command("umount", "-l", tmpMount).Run() + } + }() + + output, err = exec.Command("cp", "-a", rootfsPath+string(os.PathSeparator)+".", tmpMount+string(os.PathSeparator)).CombinedOutput() + if err != nil { + return fmt.Errorf("failed to copy rootfs into disk image: %v, output: %s", err, string(output)) + } + if !rootfsHasInit(tmpMount) { + return fmt.Errorf("failed to copy rootfs into disk image: init not found in prepared rootfs") + } + if output, err = exec.Command("umount", tmpMount).CombinedOutput(); err != nil { + return fmt.Errorf("failed to unmount prepared rootfs disk image: %v, output: %s", err, string(output)) + } + mountedTmp = false + + if err := os.Rename(rootfsPath, backupRootfs); err != nil { + return fmt.Errorf("failed to move original rootfs aside: %v", err) + } + if err := os.MkdirAll(rootfsPath, 0755); err != nil { + os.Rename(backupRootfs, rootfsPath) + return err + } + if err := m.ensureDiskImageMounted(lxcName); err != nil { + os.RemoveAll(rootfsPath) + os.Rename(backupRootfs, rootfsPath) + return err + } + if err := os.RemoveAll(backupRootfs); err != nil { + fmt.Printf("Warning: failed to remove old rootfs backup %s: %v\n", backupRootfs, err) + } + return nil +} + +func (m *Manager) ensureDiskImageMounted(lxcName string) error { + containerDir := filepath.Join(m.LxcPath, lxcName) + rootfsPath := filepath.Join(containerDir, "rootfs") + imagePath := filepath.Join(containerDir, "rootfs.img") + if _, err := os.Stat(imagePath); os.IsNotExist(err) { + return nil + } + if diskImageMounted(lxcName, rootfsPath) { + return nil + } + if err := os.MkdirAll(rootfsPath, 0755); err != nil { + return err + } + output, err := exec.Command("mount", "-o", "loop", imagePath, rootfsPath).CombinedOutput() + if err != nil { + return fmt.Errorf("failed to mount rootfs disk image: %v, output: %s", err, string(output)) + } + return nil +} + +func rootfsHasInit(rootfsPath string) bool { + for _, rel := range []string{ + "sbin/init", + "usr/lib/systemd/systemd", + "lib/systemd/systemd", + "bin/busybox", + "bin/sh", + } { + if _, err := os.Stat(filepath.Join(rootfsPath, rel)); err == nil { + return true + } + } + // NixOS has init under a hash-named nix store path; check nix/store for any init + nixStore := filepath.Join(rootfsPath, "nix", "store") + if entries, err := os.ReadDir(nixStore); err == nil { + for _, entry := range entries { + if entry.IsDir() && strings.Contains(entry.Name(), "nixos-system-") { + if _, err := os.Stat(filepath.Join(nixStore, entry.Name(), "init")); err == nil { + return true + } + if _, err := os.Stat(filepath.Join(nixStore, entry.Name(), "systemd")); err == nil { + return true + } + } + } + } + return false +} + +func diskImageMounted(lxcName, rootfsPath string) bool { + _ = lxcName + target, err := findmntValue(rootfsPath, "TARGET") + if err != nil { + return false + } + targetAbs, err := filepath.Abs(strings.TrimSpace(target)) + if err != nil { + return false + } + rootfsAbs, err := filepath.Abs(rootfsPath) + if err != nil { + return false + } + return targetAbs == rootfsAbs +} + +func applyXFSProjectQuota(rootfsPath, lxcName string, diskGB int) error { + options, err := findmntValue(rootfsPath, "OPTIONS") + if err != nil { + return err + } + if !hasProjectQuotaOption(options) { + return errors.New("xfs project quota is not enabled; remount with prjquota before creating containers") + } + mountPoint, err := findmntValue(rootfsPath, "TARGET") + if err != nil { + return err + } + projectID := projectQuotaID(lxcName) + projectName := "clicd-" + lxcName + if err := ensureProjectQuotaFiles(projectID, projectName, rootfsPath); err != nil { + return err + } + output, err := exec.Command("xfs_quota", "-x", + "-c", "project -s "+projectName, + "-c", fmt.Sprintf("limit -p bhard=%dg %s", diskGB, projectName), + mountPoint, + ).CombinedOutput() + if err != nil { + return fmt.Errorf("failed to apply xfs project quota: %v, output: %s", err, string(output)) + } + return nil +} + +func applyExt4ProjectQuota(rootfsPath, lxcName string, diskGB int) error { + options, err := findmntValue(rootfsPath, "OPTIONS") + if err != nil { + return err + } + if !hasProjectQuotaOption(options) { + return errors.New("ext4 project quota is not enabled; remount with prjquota before creating containers") + } + mountPoint, err := findmntValue(rootfsPath, "TARGET") + if err != nil { + return err + } + projectID := projectQuotaID(lxcName) + output, err := exec.Command("chattr", "-p", strconv.Itoa(projectID), rootfsPath).CombinedOutput() + if err != nil { + return fmt.Errorf("failed to assign ext4 project id: %v, output: %s", err, string(output)) + } + hardKB := diskGB * 1024 * 1024 + output, err = exec.Command("setquota", "-P", strconv.Itoa(projectID), "0", strconv.Itoa(hardKB), "0", "0", mountPoint).CombinedOutput() + if err != nil { + return fmt.Errorf("failed to apply ext4 project quota: %v, output: %s", err, string(output)) + } + return nil +} + +func findmntValue(path, field string) (string, error) { + out, err := exec.Command("findmnt", "-T", path, "-no", field).Output() + if err != nil { + return "", err + } + value := strings.TrimSpace(string(out)) + if value == "" { + return "", fmt.Errorf("findmnt returned empty %s for %s", field, path) + } + return value, nil +} + +func hasProjectQuotaOption(options string) bool { + for _, option := range strings.Split(options, ",") { + option = strings.TrimSpace(option) + if option == "prjquota" || option == "pquota" || option == "project" { + return true + } + } + return false +} + +func projectQuotaID(lxcName string) int { + idPart := strings.TrimPrefix(lxcName, "ct-") + id, err := strconv.Atoi(idPart) + if err != nil { + id = 1 + } + return 200000 + id +} + +func ensureProjectQuotaFiles(projectID int, projectName, rootfsPath string) error { + projectsLine := fmt.Sprintf("%d:%s", projectID, rootfsPath) + if err := appendUniqueLine("/etc/projects", projectsLine); err != nil { + return err + } + projidLine := fmt.Sprintf("%s:%d", projectName, projectID) + return appendUniqueLine("/etc/projid", projidLine) +} + +func appendUniqueLine(path, line string) error { + data, _ := os.ReadFile(path) + for _, existing := range strings.Split(string(data), "\n") { + if strings.TrimSpace(existing) == line { + return nil + } + } + f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644) + if err != nil { + return err + } + defer f.Close() + if len(data) > 0 && !strings.HasSuffix(string(data), "\n") { + if _, err := f.WriteString("\n"); err != nil { + return err + } + } + _, err = f.WriteString(line + "\n") + return err +} + +func findSeccompProfile() (string, error) { + for _, path := range []string{ + "/usr/share/lxc/config/common.seccomp", + "/usr/share/lxc/config/common.seccomp.policy", + "/etc/lxc/common.seccomp", + } { + if _, err := os.Stat(path); err == nil { + return path, nil + } + } + return "", errors.New("required LXC seccomp profile not found") +} + +func findAppArmorProfile() (string, error) { + data, err := os.ReadFile("/sys/kernel/security/apparmor/profiles") + if err != nil { + return "", fmt.Errorf("apparmor is required but not available: %v", err) + } + profiles := string(data) + for _, profile := range []string{"lxc-container-default-cgns", "lxc-container-default"} { + if strings.Contains(profiles, profile+" ") || strings.Contains(profiles, profile+" (") { + return profile, nil + } + } + return "", errors.New("required LXC AppArmor profile not loaded") +} + +func unprivilegedIDMap() (int, int, error) { + if err := ensureSubIDRange("/etc/subuid", "root", 100000, 65536); err != nil { + return 0, 0, err + } + if err := ensureSubIDRange("/etc/subgid", "root", 100000, 65536); err != nil { + return 0, 0, err + } + uidBase, err := parseSubIDRange("/etc/subuid", "root") + if err != nil { + return 0, 0, err + } + gidBase, err := parseSubIDRange("/etc/subgid", "root") + if err != nil { + return 0, 0, err + } + return uidBase, gidBase, nil +} + +func ensureSubIDRange(path, user string, start, count int) error { + if _, err := parseSubIDRange(path, user); err == nil { + return nil + } + f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644) + if err != nil { + return fmt.Errorf("failed to update %s: %v", path, err) + } + defer f.Close() + if _, err := f.WriteString(fmt.Sprintf("%s:%d:%d\n", user, start, count)); err != nil { + return fmt.Errorf("failed to update %s: %v", path, err) + } + return nil +} + +func parseSubIDRange(path, user string) (int, error) { + data, err := os.ReadFile(path) + if err != nil { + return 0, fmt.Errorf("failed to read %s: %v", path, err) + } + for _, line := range strings.Split(string(data), "\n") { + fields := strings.Split(strings.TrimSpace(line), ":") + if len(fields) != 3 || fields[0] != user { + continue + } + start, startErr := strconv.Atoi(fields[1]) + count, countErr := strconv.Atoi(fields[2]) + if startErr == nil && countErr == nil && count >= 65536 { + return start, nil + } + } + return 0, fmt.Errorf("%s must contain a %s subordinate id range with at least 65536 ids", path, user) +} + +func (m *Manager) shiftRootfsForUnprivileged(lxcName string) error { + uidBase, gidBase, err := unprivilegedIDMap() + if err != nil { + return err + } + rootfsPath := filepath.Join(m.LxcPath, lxcName, "rootfs") + marker := filepath.Join(rootfsPath, ".clicd-unprivileged-shifted") + if _, err := os.Stat(marker); err == nil { + return nil + } + + if err := filepath.WalkDir(rootfsPath, func(path string, _ os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + info, err := os.Lstat(path) + if err != nil { + return err + } + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return fmt.Errorf("failed to read uid/gid for %s", path) + } + uid := int(stat.Uid) + gid := int(stat.Gid) + if uid >= uidBase && uid < uidBase+65536 && gid >= gidBase && gid < gidBase+65536 { + return nil + } + if uid >= 0 && uid < 65536 { + uid += uidBase + } + if gid >= 0 && gid < 65536 { + gid += gidBase + } + return syscall.Lchown(path, uid, gid) + }); err != nil { + return fmt.Errorf("failed to shift rootfs ownership for unprivileged LXC: %v", err) + } + + if err := os.WriteFile(marker, []byte("1\n"), 0644); err != nil { + return err + } + if err := syscall.Lchown(marker, uidBase, gidBase); err != nil { + return err + } + + // Fix container directory permissions: unprivileged init runs as ns UID 0 + // (host UID 100000), which is "other" on the host. lxc-create sets the + // container dir to 770, so we need o+x to let the container process + // traverse into the directory and access rootfs. + containerDir := filepath.Join(m.LxcPath, lxcName) + if err := os.Chmod(containerDir, 0771); err != nil { + return fmt.Errorf("failed to fix container directory permissions: %v", err) + } + return nil +} + +func (m *Manager) rootfsShifted(lxcName string) bool { + marker := filepath.Join(m.LxcPath, lxcName, "rootfs", ".clicd-unprivileged-shifted") + _, err := os.Stat(marker) + return err == nil +} + +func (m *Manager) hasUnprivilegedIDMap(lxcName string) bool { + data, err := os.ReadFile(filepath.Join(m.LxcPath, lxcName, "config")) + if err != nil { + return false + } + content := string(data) + return strings.Contains(content, "lxc.idmap = u 0 ") && strings.Contains(content, "lxc.idmap = g 0 ") +} + +// StartContainer starts an LXC container by its ID +func (m *Manager) StartContainer(id int) error { + c := config.FindContainer(id) + if c == nil { + return fmt.Errorf("container not found: %d", id) + } + lxcName := c.LxcName() + + if err := m.ensureDiskImageMounted(lxcName); err != nil { + return err + } + if m.hasUnprivilegedIDMap(lxcName) && !m.rootfsShifted(lxcName) { + if err := m.shiftRootfsForUnprivileged(lxcName); err != nil { + return err + } + } + if m.rootfsShifted(lxcName) { + if err := m.applyResourceLimits(lxcName, ContainerConfig{ + Name: c.Name, + TemplateID: c.Template, + VCPU: c.VCPU, + RAMMB: c.RAMMB, + DiskGB: c.DiskGB, + NetworkBWMbps: c.NetworkBWMbps, + MonthlyTrafficGB: c.MonthlyTrafficGB, + IOSpeedMBps: c.IOSpeedMBps, + AssignIPv6: c.IPv6 != "", + ExpiresAt: c.ExpiresAt, + }); err != nil { + return err + } + } + if c.IPv6 != "" { + if err := m.applyIPv6Config(lxcName, c.IPv6); err != nil { + return err + } + if err := m.ApplyIPv6(id); err != nil { + return err + } + } + + logFile := filepath.Join(os.TempDir(), "clicd-"+lxcName+"-start.log") + os.Remove(logFile) + cmd := exec.Command("lxc-start", "-n", lxcName, "-d", "--logfile", logFile, "--logpriority", "DEBUG") + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("failed to start container: %v, output: %s, lxc log: %s", err, string(output), tailFile(logFile, 80)) + } + + config.UpdateContainerStatus(id, "running") + + var ip string + for retry := 0; retry < 10; retry++ { + time.Sleep(2 * time.Second) + ip, err = m.GetContainerIP(lxcName) + if err == nil && ip != "" { + break + } + } + if ip != "" { + c = config.FindContainer(id) + if c != nil { + c.IP = ip + config.SaveConfig() + } + } + if ip == "" { + if repairedIP, repairErr := m.EnsureContainerIPv4(id); repairErr == nil && repairedIP != "" { + ip = repairedIP + c = config.FindContainer(id) + } else if repairErr != nil { + fmt.Printf("Warning: failed to prepare IPv4 for %s: %v\n", lxcName, repairErr) + } + } + + if ip != "" { + if err := m.EnsureSSH(id); err != nil { + return err + } + } + + if current := config.FindContainer(id); current != nil { + c = current + } + if err := m.ApplyContainerLimits(c); err != nil { + fmt.Printf("Warning: failed to apply runtime resource limits for %s: %v\n", lxcName, err) + } + + if err := m.ApplyPortMappings(id); err != nil { + fmt.Printf("Warning: failed to apply port mappings: %v\n", err) + } + if c.IPv6 != "" { + if err := m.ApplyIPv6(id); err != nil { + fmt.Printf("Warning: failed to apply IPv6 routing for %s: %v\n", lxcName, err) + } + } + + fmt.Printf("Container %d (%s) started, IP: %s\n", id, c.Name, ip) + return nil +} + +// applyBandwidthLimit applies tc-based bandwidth limit on container's veth interface +// ApplyContainerLimits re-applies resource limits (CPU, RAM, IO, BW) to a running container. +func (m *Manager) ApplyContainerLimits(c *config.Container) error { + if c == nil || c.Status != "running" { + return nil + } + lxcName := c.LxcName() + + // CPU: write cpu.max + cpuQuota := int(c.VCPU * 100000) + cpuLine := fmt.Sprintf("%d 100000", cpuQuota) + for _, path := range []string{ + fmt.Sprintf("/sys/fs/cgroup/lxc/%s/cpu.max", lxcName), + fmt.Sprintf("/sys/fs/cgroup/lxc.payload.%s/cpu.max", lxcName), + } { + os.WriteFile(path, []byte(cpuLine), 0644) + } + + // Memory: write memory.max + ramBytes := int64(c.RAMMB) * 1024 * 1024 + memLine := fmt.Sprintf("%d", ramBytes) + for _, path := range []string{ + fmt.Sprintf("/sys/fs/cgroup/lxc/%s/memory.max", lxcName), + fmt.Sprintf("/sys/fs/cgroup/lxc.payload.%s/memory.max", lxcName), + } { + os.WriteFile(path, []byte(memLine), 0644) + } + + // IO speed: write io.max + if c.IOSpeedMBps > 0 { + ioLines, err := m.ioLimitLines(lxcName, c.IOSpeedMBps) + if err != nil { + return err + } + ioLine := strings.Join(ioLines, "\n") + for _, path := range []string{ + fmt.Sprintf("/sys/fs/cgroup/lxc/%s/io.max", lxcName), + fmt.Sprintf("/sys/fs/cgroup/lxc.payload.%s/io.max", lxcName), + } { + os.WriteFile(path, []byte(ioLine), 0644) + } + } + + // Network bandwidth + if c.NetworkBWMbps > 0 { + m.applyBandwidthLimit(lxcName, c.NetworkBWMbps) + } else { + m.cleanupBandwidthLimit(lxcName) + } + return nil +} + +func (m *Manager) applyBandwidthLimit(lxcName string, mbps int) { + veth := m.getContainerVethByNS(lxcName) + if veth == "" { + fmt.Printf("Warning: could not find veth for %s\n", lxcName) + return + } + rate := fmt.Sprintf("%dmbit", mbps) + burst := fmt.Sprintf("%dkbit", mbps*100) + exec.Command("tc", "qdisc", "del", "dev", veth, "root").Run() + exec.Command("tc", "qdisc", "add", "dev", veth, "root", "handle", "1:", "htb", "default", "10").Run() + exec.Command("tc", "class", "add", "dev", veth, "parent", "1:", "classid", "1:10", "htb", "rate", rate, "burst", burst).Run() + fmt.Printf("Bandwidth limit: %s = %d Mbps on %s\n", lxcName, mbps, veth) +} + +func (m *Manager) cleanupBandwidthLimit(lxcName string) { + veth := m.getContainerVethByNS(lxcName) + if veth != "" { + exec.Command("tc", "qdisc", "del", "dev", veth, "root").Run() + } +} + +func (m *Manager) getContainerVethByNS(lxcName string) string { + pid := m.getContainerInitPID(lxcName) + if pid == "" { + return "" + } + cmd := exec.Command("sh", "-c", + fmt.Sprintf("nsenter -t %s -n ip -o link show 2>/dev/null | grep -oP 'eth0@if\\K[0-9]+'", pid)) + out, _ := cmd.Output() + ifIdx := strings.TrimSpace(string(out)) + if ifIdx == "" { + return "" + } + cmd2 := exec.Command("sh", "-c", + fmt.Sprintf("ip -o link show | grep '^%s:' | grep -oP 'veth[^:@]+'", ifIdx)) + out2, _ := cmd2.Output() + return strings.TrimSpace(string(out2)) +} + +// StopContainer stops an LXC container by its ID +func (m *Manager) StopContainer(id int) error { + c := config.FindContainer(id) + if c == nil { + return fmt.Errorf("container not found: %d", id) + } + lxcName := c.LxcName() + + status, _ := m.GetContainerStatus(lxcName) + if status != "running" { + config.UpdateContainerStatus(id, "stopped") + m.CleanPortMappings(id) + m.cleanupBandwidthLimit(lxcName) + return nil + } + + m.CleanPortMappings(id) + m.cleanupBandwidthLimit(lxcName) + + cmd := exec.Command("lxc-stop", "-n", lxcName) + output, err := cmd.CombinedOutput() + if err != nil { + if strings.Contains(string(output), "not running") { + config.UpdateContainerStatus(id, "stopped") + return nil + } + return fmt.Errorf("failed to stop container: %v, output: %s", err, string(output)) + } + + config.UpdateContainerStatus(id, "stopped") + fmt.Printf("Container %d (%s) stopped\n", id, c.Name) + return nil +} + +// RestartContainer restarts an LXC container by its ID +func (m *Manager) RestartContainer(id int) error { + c := config.FindContainer(id) + if c == nil { + return fmt.Errorf("container not found: %d", id) + } + + status, _ := m.GetContainerStatus(c.LxcName()) + if status == "running" { + if err := m.StopContainer(id); err != nil { + if !strings.Contains(err.Error(), "not running") { + return err + } + } + time.Sleep(1 * time.Second) + } + return m.StartContainer(id) +} + +// EnsureContainerIPv4 brings eth0 up and asks the guest network stack for DHCP. +func (m *Manager) EnsureContainerIPv4(id int) (string, error) { + c := config.FindContainer(id) + if c == nil { + return "", fmt.Errorf("container not found: %d", id) + } + lxcName := c.LxcName() + status, _ := m.GetContainerStatus(lxcName) + if status != "running" { + return "", fmt.Errorf("container %d is not running; cannot configure IPv4", id) + } + + if ip, err := m.GetContainerIP(lxcName); err == nil && ip != "" { + c.IP = ip + config.SaveConfig() + return ip, nil + } + + script := ` +set +e +ip link set lo up 2>/dev/null +ip link set eth0 up 2>/dev/null + +if command -v systemctl >/dev/null 2>&1; then + systemctl start NetworkManager >/dev/null 2>&1 + systemctl start systemd-networkd >/dev/null 2>&1 + systemctl start networking >/dev/null 2>&1 +fi +if command -v rc-service >/dev/null 2>&1; then + rc-service networking start >/dev/null 2>&1 +fi +if command -v nmcli >/dev/null 2>&1; then + nmcli networking on >/dev/null 2>&1 + nmcli device set eth0 managed yes >/dev/null 2>&1 + nmcli connection up eth0 >/dev/null 2>&1 || nmcli device connect eth0 >/dev/null 2>&1 +fi +if command -v dhclient >/dev/null 2>&1; then + timeout 12 dhclient -4 -v eth0 >/dev/null 2>&1 +elif command -v dhcpcd >/dev/null 2>&1; then + pkill dhcpcd >/dev/null 2>&1 || true + rm -f /run/dhcpcd*.pid /var/lib/dhcpcd/* /run/dhcpcd/* 2>/dev/null + timeout 20 dhcpcd -4 -q -w -K eth0 >/dev/null 2>&1 +elif command -v udhcpc >/dev/null 2>&1; then + timeout 12 udhcpc -i eth0 -q >/dev/null 2>&1 +elif command -v busybox >/dev/null 2>&1; then + timeout 12 busybox udhcpc -i eth0 -q >/dev/null 2>&1 +fi +ip -4 addr show eth0 2>/dev/null | awk '/inet / {sub(/\/.*/, "", $2); print $2; exit}' +` + ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, "lxc-attach", "-n", lxcName, "--", "sh", "-c", script) + output, err := cmd.CombinedOutput() + if ctx.Err() == context.DeadlineExceeded { + return "", fmt.Errorf("timed out waiting for IPv4 DHCP in %s", lxcName) + } + if err != nil { + return "", fmt.Errorf("failed to run IPv4 repair in %s: %v, output: %s", lxcName, err, string(output)) + } + ip := strings.TrimSpace(string(output)) + if ip == "" { + return "", fmt.Errorf("no IPv4 address after DHCP repair in %s", lxcName) + } + c.IP = ip + config.SaveConfig() + return ip, nil +} + +// WarmSSH waits briefly for container networking metadata and then ensures sshd +// is installed, configured, running, and using the saved root password. +func (m *Manager) WarmSSH(id int) error { + c := config.FindContainer(id) + if c == nil { + return fmt.Errorf("container not found: %d", id) + } + lxcName := c.LxcName() + status, _ := m.GetContainerStatus(lxcName) + if status != "running" { + return fmt.Errorf("container %d is not running; cannot warm SSH", id) + } + + for retry := 0; retry < 15; retry++ { + if ip, err := m.GetContainerIP(lxcName); err == nil && ip != "" { + if current := config.FindContainer(id); current != nil { + current.IP = ip + config.SaveConfig() + } + break + } + time.Sleep(2 * time.Second) + } + if current := config.FindContainer(id); current != nil && current.IP == "" { + if ip, err := m.EnsureContainerIPv4(id); err == nil && ip != "" { + current.IP = ip + config.SaveConfig() + } + } + + return m.EnsureSSH(id) +} + +func (m *Manager) WarmSSHAsync(id int, reason string) { + if _, loaded := sshWarmupScheduled.LoadOrStore(id, struct{}{}); loaded { + return + } + go func() { + sshWarmupSem <- struct{}{} + defer func() { + <-sshWarmupSem + sshWarmupScheduled.Delete(id) + }() + + if err := m.WarmSSH(id); err != nil { + if c := config.FindContainer(id); c != nil { + fmt.Printf("Warning: SSH warmup failed for %s (%s, %s): %v\n", c.LxcName(), c.Name, reason, err) + } else { + fmt.Printf("Warning: SSH warmup failed for container %d (%s): %v\n", id, reason, err) + } + } + }() +} + +func sshEnsureLock(id int) *sync.Mutex { + lock, _ := sshEnsureLocks.LoadOrStore(id, &sync.Mutex{}) + return lock.(*sync.Mutex) +} + +// DestroyContainer destroys an LXC container by its ID +func (m *Manager) DestroyContainer(id int) error { + if id <= 0 { + return fmt.Errorf("invalid container id: %d", id) + } + c := config.FindContainer(id) + if c == nil { + return fmt.Errorf("container not found: %d", id) + } + lxcName := c.LxcName() + + if err := m.StopContainer(id); err != nil { + return fmt.Errorf("failed to stop container before destroy: %v", err) + } + time.Sleep(1 * time.Second) + + containerDir := filepath.Join(m.LxcPath, lxcName) + m.detachContainerMounts(containerDir) + m.detachContainerLoopDevices(containerDir) + + // Retry lxc-destroy up to 3 times, since LXC may need time to release resources + var destroyErr error + for attempt := 0; attempt < 3; attempt++ { + cmd := exec.Command("lxc-destroy", "-n", lxcName, "-f") + output, err := cmd.CombinedOutput() + if err == nil { + destroyErr = nil + break + } + out := string(output) + if strings.Contains(strings.ToLower(out), "does not exist") || + strings.Contains(strings.ToLower(out), "not found") || + strings.Contains(strings.ToLower(out), "is not defined") { + destroyErr = nil + break + } + destroyErr = fmt.Errorf("failed to destroy container (attempt %d/3): %v, output: %s", attempt+1, err, out) + if attempt < 2 { + // Wait for LXC to release resources before retry + time.Sleep(2 * time.Second) + m.detachContainerMounts(containerDir) + m.detachContainerLoopDevices(containerDir) + } + } + + if err := m.cleanupContainerStorage(lxcName); err != nil { + if destroyErr != nil { + return fmt.Errorf("%v; cleanup also failed: %v", destroyErr, err) + } + return err + } + if status, err := m.GetContainerStatus(lxcName); err == nil { + if destroyErr != nil { + return fmt.Errorf("%v; container still exists after cleanup with status %s", destroyErr, status) + } + return fmt.Errorf("container still exists after cleanup with status %s", status) + } + + if !config.RemoveContainer(id) { + return fmt.Errorf("container destroyed but config entry was not removed: %d", id) + } + if config.FindContainer(id) != nil { + return fmt.Errorf("container destroyed but config entry still exists: %d", id) + } + fmt.Printf("Container %d destroyed\n", id) + return nil +} + +// EnsureSSH installs and starts sshd, enables root password login, and verifies port 22. +func (m *Manager) EnsureSSH(id int) error { + lock := sshEnsureLock(id) + lock.Lock() + defer lock.Unlock() + + c := config.FindContainer(id) + if c == nil { + return fmt.Errorf("container not found: %d", id) + } + lxcName := c.LxcName() + + status, _ := m.GetContainerStatus(lxcName) + if status != "running" { + return fmt.Errorf("container %d is not running; cannot configure SSH", id) + } + + if c.SSHPassword == "" { + c.SSHPassword = generateRandomString(16) + config.SaveConfig() + } + + script := sshSetupScript(c.SSHPassword, true) + + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, "lxc-attach", "-n", lxcName, "--", "sh", "-c", script) + output, err := cmd.CombinedOutput() + if ctx.Err() == context.DeadlineExceeded { + return fmt.Errorf("timed out configuring SSH in container %d after 90s; package manager or service startup may be stuck, output: %s", id, string(output)) + } + if err != nil { + return fmt.Errorf("failed to configure SSH in container %d: %v, output: %s", id, err, string(output)) + } + + if c.IP == "" { + if ip, ipErr := m.GetContainerIP(lxcName); ipErr == nil && ip != "" { + c.IP = ip + config.SaveConfig() + } + } + if c.IP != "" { + if mapErr := m.ApplyPortMappings(id); mapErr != nil { + fmt.Printf("Warning: failed to refresh SSH port mapping for %s: %v\n", lxcName, mapErr) + } + } + + fmt.Printf("SSH ready in container %d (root password login enabled)\n", id) + return nil +} + +func (m *Manager) quickEnsureSSHPassword(lxcName, password string) error { + if password == "" { + return fmt.Errorf("empty SSH password") + } + ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, "lxc-attach", "-n", lxcName, "--", "sh", "-c", + fmt.Sprintf("printf '%%s:%%s\\n' root %s | chpasswd", shellQuote(password))) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("failed to update SSH password quickly: %v, output: %s", err, string(output)) + } + return nil +} + +func (m *Manager) containerPortListening(lxcName string, port int) bool { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + check := fmt.Sprintf("(ss -ltn 2>/dev/null || netstat -tln 2>/dev/null) | grep -Eq '(^|[[:space:]])[^[:space:]]*:%d[[:space:]]'", port) + return exec.CommandContext(ctx, "lxc-attach", "-n", lxcName, "--", "sh", "-c", check).Run() == nil +} + +func sshSetupScript(password string, startService bool) string { + script := `set -u +ROOT_PASSWORD=` + shellQuote(password) + ` + +# DNS setup: handle both traditional /etc/resolv.conf and systemd-resolved (Ubuntu 24.04). +# On modern distros, /etc/resolv.conf is a symlink managed by systemd-resolved. +# Remove any existing symlink and write a real file so DNS always works in LXC. +if [ -L /etc/resolv.conf ] 2>/dev/null; then + rm -f /etc/resolv.conf +fi +# Also try resolvectl for systemd-resolved setups +if command -v resolvectl >/dev/null 2>&1; then + resolvectl dns eth0 10.0.3.1 2>/dev/null || true + resolvectl dns eth0 8.8.8.8 2>/dev/null || true + resolvectl domain eth0 '~.' 2>/dev/null || true +fi +# Check if we have a usable (non-localhost) nameserver; if not, force-write one. +# Avoid the trap where systemd stub resolver puts "nameserver 127.0.0.53" +# but doesn't actually resolve anything. +if ! grep -q '^nameserver [1-9]' /etc/resolv.conf 2>/dev/null; then + echo "nameserver 10.0.3.1" > /etc/resolv.conf + echo "nameserver 8.8.8.8" >> /etc/resolv.conf +fi +export DEBIAN_FRONTEND=noninteractive +export APT_LISTCHANGES_FRONTEND=none + +run_timeout() { + if command -v timeout >/dev/null 2>&1; then + timeout "$@" + else + shift + "$@" + fi +} + +has_sshd() { + command -v sshd >/dev/null 2>&1 || [ -x /usr/sbin/sshd ] || [ -x /sbin/sshd ] +} + +sshd_path() { + if command -v sshd >/dev/null 2>&1; then + command -v sshd + elif [ -x /usr/sbin/sshd ]; then + printf /usr/sbin/sshd + elif [ -x /sbin/sshd ]; then + printf /sbin/sshd + else + return 1 + fi +} + +install_sshd() { + if has_sshd; then + return 0 + fi + if command -v apt-get >/dev/null 2>&1; then + for i in 1 2; do + run_timeout 20 dpkg --configure -a >/dev/null 2>&1 || true + run_timeout 45 apt-get update -o Acquire::Retries=2 -o Acquire::http::Timeout=15 && + run_timeout 90 apt-get install -y --no-install-recommends -o Dpkg::Options::=--force-confold openssh-server passwd iproute2 procps net-tools && + return 0 + sleep 3 + done + elif command -v apk >/dev/null 2>&1; then + run_timeout 60 apk add --no-cache openssh-server openssh-client shadow iproute2 procps net-tools && return 0 + elif command -v pacman >/dev/null 2>&1; then + run_timeout 45 pacman -Syu --noconfirm >/dev/null 2>&1 || true + run_timeout 90 pacman -S --noconfirm openssh shadow iproute2 procps-ng net-tools && return 0 + elif command -v dnf >/dev/null 2>&1; then + run_timeout 90 dnf install -y openssh-server openssh-clients passwd iproute procps-ng net-tools && return 0 + elif command -v yum >/dev/null 2>&1; then + run_timeout 90 yum install -y openssh-server openssh-clients passwd iproute procps-ng net-tools && return 0 + elif command -v nix-env >/dev/null 2>&1; then + { run_timeout 120 nix-env -iA nixos.openssh >/dev/null 2>&1 && return 0; } || true + # NixOS cloud images may already have sshd or use different package paths + # Fall through to let the script try whatever sshd is available + fi + return 1 +} + +set_sshd_option() { + key="$1" + value="$2" + file=/etc/ssh/sshd_config + tmp="${file}.clicd" + touch "$file" + awk -v key="$key" -v value="$value" ' + BEGIN { done=0; inmatch=0 } + /^[[:space:]]*Match[[:space:]]/ { + if (!done) { print key " " value; done=1 } + inmatch=1 + print + next + } + !inmatch && $0 ~ "^[#[:space:]]*" key "[[:space:]]+" { + if (!done) { print key " " value; done=1 } + next + } + { print } + END { if (!done) print key " " value } + ' "$file" >"$tmp" && cat "$tmp" >"$file" + rm -f "$tmp" +} + +install_sshd || exit 30 + +mkdir -p /run/sshd /var/run/sshd /etc/ssh /etc/ssh/sshd_config.d +ssh-keygen -A >/dev/null 2>&1 || true + +cat >/etc/ssh/sshd_config.d/99-clicd.conf <<'EOF' +PermitRootLogin yes +PasswordAuthentication yes +KbdInteractiveAuthentication no +ChallengeResponseAuthentication no +UsePAM no +EOF + +set_sshd_option PermitRootLogin yes +set_sshd_option PasswordAuthentication yes +set_sshd_option KbdInteractiveAuthentication no +set_sshd_option ChallengeResponseAuthentication no +set_sshd_option UsePAM no + +if [ -n "$ROOT_PASSWORD" ]; then + printf '%s:%s\n' root "$ROOT_PASSWORD" | chpasswd || exit 31 + passwd -u root >/dev/null 2>&1 || true +fi + +if command -v rc-update >/dev/null 2>&1; then + rc-update add sshd default >/dev/null 2>&1 || true +fi +if command -v systemctl >/dev/null 2>&1; then + systemctl stop ssh.socket 2>/dev/null || true + systemctl disable ssh.socket 2>/dev/null || true + systemctl enable ssh >/dev/null 2>&1 || systemctl enable sshd >/dev/null 2>&1 || true +fi +if command -v update-rc.d >/dev/null 2>&1; then + update-rc.d ssh defaults >/dev/null 2>&1 || true +fi +if command -v chkconfig >/dev/null 2>&1; then + chkconfig sshd on >/dev/null 2>&1 || true +fi + +SSHD_BIN="$(sshd_path)" || exit 32 +"$SSHD_BIN" -t -f /etc/ssh/sshd_config >/tmp/clicd-sshd-test.log 2>&1 || { + cat /tmp/clicd-sshd-test.log + exit 32 +} +` + if !startService { + return script + } + return script + ` +# Disable socket-activated SSH (Ubuntu 24.04 default) to avoid conflicts. +if command -v systemctl >/dev/null 2>&1; then + systemctl stop ssh.socket 2>/dev/null || true + systemctl disable ssh.socket 2>/dev/null || true +fi +if command -v systemctl >/dev/null 2>&1 && [ -d /run/systemd/system ]; then + systemctl restart ssh >/dev/null 2>&1 || systemctl restart sshd >/dev/null 2>&1 || true +fi +service ssh restart >/dev/null 2>&1 || + service sshd restart >/dev/null 2>&1 || + rc-service sshd restart >/dev/null 2>&1 || + /etc/init.d/ssh restart >/dev/null 2>&1 || + /etc/init.d/sshd restart >/dev/null 2>&1 || + true + +if ! (ss -ltn 2>/dev/null || netstat -tln 2>/dev/null) | grep -Eq '(^|[[:space:]])[^[:space:]]*:22[[:space:]]'; then + pkill -x sshd >/dev/null 2>&1 || killall sshd >/dev/null 2>&1 || true + rm -f /run/sshd.pid /var/run/sshd.pid + "$SSHD_BIN" -f /etc/ssh/sshd_config >/dev/null 2>&1 || exit 32 +fi + +for i in 1 2 3 4 5; do + if (ss -ltn 2>/dev/null || netstat -tln 2>/dev/null) | grep -Eq '(^|[[:space:]])[^[:space:]]*:22[[:space:]]'; then + exit 0 + fi + sleep 1 +done +pgrep -x sshd >/dev/null 2>&1 || exit 33 +` +} + +// ResetSSHPassword resets the root password of a container +func (m *Manager) ResetSSHPassword(id int) (string, error) { + c := config.FindContainer(id) + if c == nil { + return "", fmt.Errorf("container not found: %d", id) + } + lxcName := c.LxcName() + + newPassword := generateRandomString(16) + + if c.Status == "running" { + c.SSHPassword = newPassword + config.SaveConfig() + if err := m.EnsureSSH(id); err != nil { + return "", err + } + } else { + if err := m.ensureDiskImageMounted(lxcName); err != nil { + return "", err + } + rootfsPath := filepath.Join(m.LxcPath, lxcName, "rootfs") + if err := m.preconfigureSSH(rootfsPath, newPassword, c.Template); err != nil { + return "", fmt.Errorf("failed to configure SSH: %v", err) + } + cmd := m.rootfsCommand(rootfsPath, "sh", "-c", fmt.Sprintf("printf '%%s:%%s\\n' root %s | chpasswd", shellQuote(newPassword))) + output, err := cmd.CombinedOutput() + if err != nil { + return "", fmt.Errorf("failed to set password: %v, output: %s", err, string(output)) + } + c.SSHPassword = newPassword + config.SaveConfig() + } + + return newPassword, nil +} + +func (m *Manager) rootfsCommand(rootfsPath string, args ...string) *exec.Cmd { + marker := filepath.Join(rootfsPath, ".clicd-unprivileged-shifted") + if _, err := os.Stat(marker); err == nil { + uidBase, gidBase, mapErr := unprivilegedIDMap() + if mapErr == nil { + cmdArgs := []string{ + "-m", fmt.Sprintf("u:0:%d:65536", uidBase), + "-m", fmt.Sprintf("g:0:%d:65536", gidBase), + "--", "chroot", rootfsPath, + } + cmdArgs = append(cmdArgs, args...) + return exec.Command("lxc-usernsexec", cmdArgs...) + } + } + cmdArgs := append([]string{rootfsPath}, args...) + return exec.Command("chroot", cmdArgs...) +} + +func (m *Manager) cleanupContainerStorage(lxcName string) error { + containerDir := filepath.Join(m.LxcPath, lxcName) + cleanPath, err := filepath.Abs(containerDir) + if err != nil { + return fmt.Errorf("failed to resolve container path: %v", err) + } + basePath, err := filepath.Abs(m.LxcPath) + if err != nil { + return fmt.Errorf("failed to resolve LXC path: %v", err) + } + if cleanPath == basePath || !strings.HasPrefix(cleanPath, basePath+string(os.PathSeparator)) { + return fmt.Errorf("refusing to remove unsafe container path: %s", cleanPath) + } + if _, err := os.Stat(cleanPath); os.IsNotExist(err) { + return nil + } + exec.Command("lxc-stop", "-n", lxcName, "-k").Run() + exec.Command("lxc-destroy", "-n", lxcName, "-f").Run() + m.detachContainerMounts(cleanPath) + m.detachContainerLoopDevices(cleanPath) + rootfs := filepath.Join(cleanPath, "rootfs") + exec.Command("umount", "-R", "-l", rootfs).Run() + m.detachContainerMounts(cleanPath) + m.detachContainerLoopDevices(cleanPath) + if err := os.RemoveAll(cleanPath); err != nil { + return fmt.Errorf("failed to remove container directory %s: %v", cleanPath, err) + } + return nil +} + +func (m *Manager) detachContainerMounts(containerDir string) { + out, err := exec.Command("findmnt", "-R", "-n", "-o", "TARGET", containerDir).Output() + if err != nil { + return + } + lines := strings.Split(strings.TrimSpace(string(out)), "\n") + for i, j := 0, len(lines)-1; i < j; i, j = i+1, j-1 { + lines[i], lines[j] = lines[j], lines[i] + } + for _, line := range lines { + target := strings.TrimSpace(line) + if target == "" { + continue + } + exec.Command("umount", "-R", "-l", target).Run() + } +} + +func (m *Manager) detachContainerLoopDevices(containerDir string) { + out, err := exec.Command("losetup", "-j", filepath.Join(containerDir, "rootfs.img")).Output() + if err != nil { + return + } + for _, line := range strings.Split(string(out), "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + device := strings.TrimSuffix(strings.SplitN(line, ":", 2)[0], ":") + if device != "" { + exec.Command("losetup", "-d", device).Run() + } + } +} + +// GetContainerStatus gets container running status +func (m *Manager) GetContainerStatus(lxcName string) (string, error) { + cmd := exec.Command("lxc-info", "-n", lxcName, "-sH") + output, err := cmd.Output() + if err != nil { + cmd2 := exec.Command("lxc-info", "-n", lxcName, "-s") + output2, err2 := cmd2.Output() + if err2 != nil { + return "unknown", err2 + } + output = output2 + } + + status := strings.TrimSpace(string(output)) + upper := strings.ToUpper(status) + if strings.Contains(upper, "RUNNING") { + return "running", nil + } + return "stopped", nil +} + +// GetContainerIP gets container IP address +func (m *Manager) GetContainerIP(lxcName string) (string, error) { + cmd := exec.Command("lxc-info", "-n", lxcName, "-iH") + output, err := cmd.Output() + if err != nil { + cmd2 := exec.Command("lxc-info", "-n", lxcName, "-i") + output2, err2 := cmd2.Output() + if err2 != nil { + return "", err2 + } + output = output2 + } + + ip := strings.TrimSpace(string(output)) + // Always prefer IPv4; IPv6 addresses break WebSSH and port forwarding. + re := regexp.MustCompile(`(\d+\.\d+\.\d+\.\d+)`) + matches := re.FindStringSubmatch(ip) + if len(matches) > 1 { + return matches[1], nil + } + // If no IPv4 found, try lxc-attach as fallback (DHCP may be delayed) + attachCmd := exec.Command("lxc-attach", "-n", lxcName, "--", "sh", "-c", "ip -4 addr show eth0 2>/dev/null | grep -oP 'inet \\K[\\d.]+' || true") + if attachOut, attachErr := attachCmd.Output(); attachErr == nil { + v4 := strings.TrimSpace(string(attachOut)) + if v4 != "" { + return v4, nil + } + } + return "", fmt.Errorf("no IPv4 address found for %s (IPv6 is disabled for containers)", lxcName) +} + +// ListContainers lists all LXC containers and updates statuses +func (m *Manager) ListContainers() ([]config.Container, error) { + containers := config.AppConfig.Containers + for i := range containers { + status, err := m.GetContainerStatus(containers[i].LxcName()) + if err == nil { + containers[i].Status = status + } + if status == "running" { + ip, err := m.GetContainerIP(containers[i].LxcName()) + if err == nil { + containers[i].IP = ip + } + } + } + return containers, nil +} + +// ReinstallContainer reinstalls the container OS +func (m *Manager) ReinstallContainer(id int, templateID string) error { + c := config.FindContainer(id) + if c == nil { + return fmt.Errorf("container not found: %d", id) + } + + tmpl := FindTemplate(templateID) + if tmpl == nil { + return fmt.Errorf("template not found: %s", templateID) + } + + lxcName := c.LxcName() + + // Stop the container first + status, _ := m.GetContainerStatus(lxcName) + if status == "running" { + m.StopContainer(id) + } + + // Clean port mappings temporarily + m.CleanPortMappings(id) + + // Destroy old LXC but keep config + exec.Command("lxc-stop", "-n", lxcName, "-k").Run() + exec.Command("lxc-destroy", "-n", lxcName, "-f").Run() + rootfs := filepath.Join(m.LxcPath, lxcName, "rootfs") + exec.Command("umount", "-R", "-l", rootfs).Run() + os.RemoveAll(rootfs) + os.Remove(filepath.Join(m.LxcPath, lxcName, "rootfs.img")) + + // Create new container with same LXC name (preserves ID) + cmd := exec.Command("lxc-create", + "-n", lxcName, + "-t", "download", + "--", + "-d", tmpl.Distro, + "-r", tmpl.Release, + "-a", tmpl.Arch, + ) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("lxc-create failed: %v, output: %s", err, string(output)) + } + + if err := m.applyDiskLimit(lxcName, c.DiskGB); err != nil { + return err + } + + // Re-apply resource limits and mandatory security hardening. + cfg := ContainerConfig{ + Name: c.Name, + TemplateID: templateID, + VCPU: c.VCPU, + RAMMB: c.RAMMB, + DiskGB: c.DiskGB, + NetworkBWMbps: c.NetworkBWMbps, + MonthlyTrafficGB: c.MonthlyTrafficGB, + IOSpeedMBps: c.IOSpeedMBps, + AssignIPv6: c.IPv6 != "", + ExpiresAt: c.ExpiresAt, + } + if err := m.applyResourceLimits(lxcName, cfg); err != nil { + return err + } + if c.IPv6 != "" { + if err := m.applyIPv6Config(lxcName, c.IPv6); err != nil { + return err + } + } + + // Set root password and pre-configure network/SSH via chroot. + rootfsPath := filepath.Join(m.LxcPath, lxcName, "rootfs") + m.preconfigureNetwork(rootfsPath, templateID) + if c.SSHPassword == "" { + c.SSHPassword = generateRandomString(16) + } + if err := m.preconfigureSSH(rootfsPath, c.SSHPassword, templateID); err != nil { + fmt.Printf("Warning: failed to pre-configure SSH in %s after reinstall: %v\n", lxcName, err) + } + if err := m.shiftRootfsForUnprivileged(lxcName); err != nil { + return err + } + setCmd := m.rootfsCommand(rootfsPath, + "sh", "-c", fmt.Sprintf("printf '%%s:%%s\\n' root %s | chpasswd", shellQuote(c.SSHPassword))) + setCmd.Run() + + // Update template and keep everything else the same + c.Template = templateID + c.Status = "running" + config.SaveConfig() + + // Start the container to trigger ensureSSH + if err := m.ensureDiskImageMounted(lxcName); err != nil { + c.Status = "stopped" + config.SaveConfig() + return err + } + logFile := filepath.Join(os.TempDir(), "clicd-"+lxcName+"-start.log") + os.Remove(logFile) + startCmd := exec.Command("lxc-start", "-n", lxcName, "-d", "--logfile", logFile, "--logpriority", "DEBUG") + if output, err := startCmd.CombinedOutput(); err != nil { + fmt.Printf("Warning: failed to start container after reinstall: %v\n", err) + c.Status = "stopped" + config.SaveConfig() + return fmt.Errorf("reinstalled but failed to start: %v, output: %s, lxc log: %s", err, string(output), tailFile(logFile, 80)) + } + + // Wait for network and install SSH + time.Sleep(5 * time.Second) + var ip string + for retry := 0; retry < 5; retry++ { + ip, _ = m.GetContainerIP(lxcName) + if ip != "" { + break + } + time.Sleep(2 * time.Second) + } + if ip != "" { + c.IP = ip + config.SaveConfig() + } + if ip != "" { + if err := m.EnsureSSH(id); err != nil { + return err + } + } + // Apply bandwidth limit after reinstall + if c.NetworkBWMbps > 0 { + m.applyBandwidthLimit(c.LxcName(), c.NetworkBWMbps) + } + if c.IPv6 != "" { + if err := m.ApplyIPv6(id); err != nil { + fmt.Printf("Warning: failed to apply IPv6 after reinstall: %v\n", err) + } + } + + fmt.Printf("Container %d (%s) reinstalled with %s\n", id, c.Name, templateID) + return nil +} + +// GetResourceUsage returns resource usage info for a container by ID. +// Rates come from the background monitor goroutine (no shared-state races). +func (m *Manager) GetResourceUsage(id int) (map[string]interface{}, error) { + c := config.FindContainer(id) + if c == nil { + return nil, fmt.Errorf("container not found: %d", id) + } + lxcName := c.LxcName() + usage := make(map[string]interface{}) + + // Read raw values + memUsage := readIntCommand(fmt.Sprintf( + "cat /sys/fs/cgroup/lxc/%[1]s/memory.current 2>/dev/null || "+ + "cat /sys/fs/cgroup/lxc.payload.%[1]s/memory.current 2>/dev/null || "+ + "cat /sys/fs/cgroup/memory/lxc/%[1]s/memory.usage_in_bytes 2>/dev/null || echo 0", shellQuote(lxcName))) + usage["memory_usage_bytes"] = memUsage + + cpuUsec := uint64(readIntCommand(fmt.Sprintf( + "(cat /sys/fs/cgroup/lxc/%[1]s/cpu.stat 2>/dev/null || "+ + "cat /sys/fs/cgroup/lxc.payload.%[1]s/cpu.stat 2>/dev/null) | "+ + "awk '/usage_usec/ {print $2; found=1} END {if (!found) print 0}'", shellQuote(lxcName)))) + usage["cpu_usage_usec"] = cpuUsec + + diskUsage := readIntCommand(fmt.Sprintf("du -s -B1 %s 2>/dev/null | awk '{print $1}' || echo 0", + shellQuote(filepath.Join(m.LxcPath, lxcName, "rootfs")))) + usage["disk_usage_bytes"] = diskUsage + + rxBytes, txBytes := m.getContainerNetworkBytes(lxcName) + usage["network_rx_bytes"] = rxBytes + usage["network_tx_bytes"] = txBytes + + readBytes, writeBytes := m.getContainerDiskIOBytes(lxcName) + usage["disk_read_bytes"] = readBytes + usage["disk_write_bytes"] = writeBytes + + // Read rates from background monitor cache (single writer, no race) + usageMu.RLock() + rate, hasRate := rateCache[lxcName] + usageMu.RUnlock() + + if hasRate && time.Since(rate.UpdatedAt) < 15*time.Second { + usage["cpu_usage_pct"] = rate.CPUPct + usage["network_rx_bps"] = rate.RXBps + usage["network_tx_bps"] = rate.TXBps + usage["disk_read_bps"] = rate.ReadBps + usage["disk_write_bps"] = rate.WriteBps + } else { + // Cache miss or stale — return zeros (monitor will populate soon) + usage["cpu_usage_pct"] = 0.0 + usage["network_rx_bps"] = 0.0 + usage["network_tx_bps"] = 0.0 + usage["disk_read_bps"] = 0.0 + usage["disk_write_bps"] = 0.0 + } + + return usage, nil +} + +func (m *Manager) getContainerNetworkBytes(lxcName string) (uint64, uint64) { + pid := m.getContainerInitPID(lxcName) + if pid == "" { + return 0, 0 + } + dir := fmt.Sprintf("/proc/%s/net", pid) + rx := readIntCommand(fmt.Sprintf("cat %s/dev 2>/dev/null | awk '{rx+=$2; tx+=$10} END {print rx}' || echo 0", shellQuote(dir))) + tx := readIntCommand(fmt.Sprintf("cat %s/dev 2>/dev/null | awk '{rx+=$2; tx+=$10} END {print tx}' || echo 0", shellQuote(dir))) + return uint64(rx), uint64(tx) +} + +func (m *Manager) getContainerDiskIOBytes(lxcName string) (uint64, uint64) { + pid := m.getContainerInitPID(lxcName) + if pid == "" { + return 0, 0 + } + // /proc/PID/io format: "field_name: value" per line + // Fields: rchar, wchar, syscr, syscw, read_bytes, write_bytes, cancelled_write_bytes + readBytes := uint64(readIntCommand(fmt.Sprintf("awk '/^read_bytes:/ {print $2}' /proc/%s/io 2>/dev/null || echo 0", pid))) + writeBytes := uint64(readIntCommand(fmt.Sprintf("awk '/^write_bytes:/ {print $2}' /proc/%s/io 2>/dev/null || echo 0", pid))) + return readBytes, writeBytes +} + +func (m *Manager) getContainerInitPID(lxcName string) string { + cmd := exec.Command("lxc-info", "-n", lxcName, "-pH") + out, err := cmd.Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(out)) +} + +// getContainerUptimeSeconds returns how long the container has been running (in seconds). +func (m *Manager) getContainerUptimeSeconds(lxcName string) float64 { + pid := m.getContainerInitPID(lxcName) + if pid == "" { + return 0 + } + // Read process starttime from /proc/PID/stat (field 22 after the closing paren) + statContent := readFile(fmt.Sprintf("/proc/%s/stat", pid)) + if statContent == "" { + return 0 + } + // Find the closing paren of comm field, then we need field 22 after that + parenIdx := strings.LastIndex(statContent, ")") + if parenIdx < 0 { + return 0 + } + fields := strings.Fields(statContent[parenIdx+2:]) + if len(fields) < 20 { + return 0 + } + // starttime is the 20th field after the comm (since state=0, ppid=1, ...) + starttime := fields[19] + ticks, err := strconv.ParseInt(starttime, 10, 64) + if err != nil { + return 0 + } + // Get system uptime + uptimeContent := readFile("/proc/uptime") + if uptimeContent == "" { + return 0 + } + uptimeParts := strings.Fields(uptimeContent) + if len(uptimeParts) < 1 { + return 0 + } + systemUptime, err := strconv.ParseFloat(uptimeParts[0], 64) + if err != nil { + return 0 + } + // Get clock ticks per second (usually 100) + clkTck := int64(100) + clkTckContent := readFile("/proc/stat") + if clkTckContent != "" { + for _, line := range strings.Split(clkTckContent, "\n") { + if strings.HasPrefix(line, "btime ") { + // Can use this to double-check but not needed + break + } + } + } + processUptime := float64(ticks) / float64(clkTck) + containerUptime := systemUptime - processUptime + if containerUptime < 0 { + containerUptime = 0 + } + return containerUptime +} + +func readFile(path string) string { + data, err := os.ReadFile(path) + if err != nil { + return "" + } + return string(data) +} + +func tailFile(path string, maxLines int) string { + data, err := os.ReadFile(path) + if err != nil { + return "" + } + lines := strings.Split(strings.TrimSpace(string(data)), "\n") + if len(lines) > maxLines { + lines = lines[len(lines)-maxLines:] + } + return strings.Join(lines, "\n") +} + +// GetContainerMemoryCgroupUsage returns memory.usage_in_bytes for a container, for usage-only queries. +func (m *Manager) GetContainerMemoryCgroupUsage(id int) int64 { + c := config.FindContainer(id) + if c == nil { + return 0 + } + return readIntCommand(fmt.Sprintf( + "cat /sys/fs/cgroup/lxc/%[1]s/memory.current 2>/dev/null || "+ + "cat /sys/fs/cgroup/lxc.payload.%[1]s/memory.current 2>/dev/null || "+ + "cat /sys/fs/cgroup/memory/lxc/%[1]s/memory.usage_in_bytes 2>/dev/null || echo 0", shellQuote(c.LxcName()))) +} + +func shellQuote(s string) string { + return fmt.Sprintf("'%s'", strings.ReplaceAll(s, "'", "'\\''")) +} + +func readIntCommand(cmdStr string) int64 { + cmd := exec.Command("sh", "-c", cmdStr) + out, err := cmd.Output() + if err != nil { + return 0 + } + val, _ := strconv.ParseInt(strings.TrimSpace(string(out)), 10, 64) + return val +} + +func generateRandomString(length int) string { + b := make([]byte, length) + rand.Read(b) + return hex.EncodeToString(b)[:length] +} + +// lastTrafficSnapshot stores previous network byte counts for delta calculation +var ( + lastTrafficSnapshot = map[string]trafficSample{} + lastTrafficSnapshotMu sync.Mutex +) + +type trafficSample struct { + RXBytes uint64 + TXBytes uint64 +} + +// AccumulateTraffic tracks container network traffic usage (delta-based, called periodically) +func (m *Manager) AccumulateTraffic() { + currentMonth := time.Now().Format("2006-01") + lastTrafficSnapshotMu.Lock() + defer lastTrafficSnapshotMu.Unlock() + + for i := range config.AppConfig.Containers { + c := &config.AppConfig.Containers[i] + if c.Status != "running" { + // Remove snapshot for stopped containers + delete(lastTrafficSnapshot, c.LxcName()) + continue + } + // Reset if new month + if c.TrafficResetDate != currentMonth { + c.TrafficUsedRX = 0 + c.TrafficUsedTX = 0 + c.TrafficResetDate = currentMonth + delete(lastTrafficSnapshot, c.LxcName()) + } + rx, tx := m.getContainerNetworkBytes(c.LxcName()) + prev, exists := lastTrafficSnapshot[c.LxcName()] + // Only add the DELTA (increment since last snapshot) + if exists && rx >= prev.RXBytes && tx >= prev.TXBytes { + c.TrafficUsedRX += int64(rx - prev.RXBytes) + c.TrafficUsedTX += int64(tx - prev.TXBytes) + } + lastTrafficSnapshot[c.LxcName()] = trafficSample{RXBytes: rx, TXBytes: tx} + } + config.SaveConfig() +} + +// GetTrafficInfo returns traffic usage info for a container +func (m *Manager) GetTrafficInfo(id int) map[string]interface{} { + c := config.FindContainer(id) + if c == nil { + return nil + } + currentMonth := time.Now().Format("2006-01") + if c.TrafficResetDate != currentMonth { + c.TrafficUsedRX = 0 + c.TrafficUsedTX = 0 + c.TrafficResetDate = currentMonth + config.SaveConfig() + } + + // Accumulate new delta since last call + lastTrafficSnapshotMu.Lock() + if c.Status == "running" { + rx, tx := m.getContainerNetworkBytes(c.LxcName()) + prev, exists := lastTrafficSnapshot[c.LxcName()] + if exists && rx >= prev.RXBytes && tx >= prev.TXBytes { + c.TrafficUsedRX += int64(rx - prev.RXBytes) + c.TrafficUsedTX += int64(tx - prev.TXBytes) + config.SaveConfig() + } + lastTrafficSnapshot[c.LxcName()] = trafficSample{RXBytes: rx, TXBytes: tx} + } + lastTrafficSnapshotMu.Unlock() + + totalUsed := c.TrafficUsedRX + c.TrafficUsedTX + limitGB := 0 + usedPct := 0.0 + if c.TrafficMode == "in_out" { + limitGB = c.TrafficInGB + c.TrafficOutGB + inUsed := float64(c.TrafficUsedRX) + outUsed := float64(c.TrafficUsedTX) + inLimit := float64(c.TrafficInGB) * 1073741824 + outLimit := float64(c.TrafficOutGB) * 1073741824 + inPct := 0.0 + outPct := 0.0 + if c.TrafficInGB > 0 { + inPct = inUsed / inLimit * 100 + } + if c.TrafficOutGB > 0 { + outPct = outUsed / outLimit * 100 + } + usedPct = inPct + if outPct > usedPct { + usedPct = outPct + } + } else { + limitGB = c.MonthlyTrafficGB + if limitGB > 0 { + usedPct = float64(totalUsed) / float64(limitGB*1073741824) * 100 + } + } + + return map[string]interface{}{ + "total_used_bytes": totalUsed, + "rx_used_bytes": c.TrafficUsedRX, + "tx_used_bytes": c.TrafficUsedTX, + "mode": c.TrafficMode, + "limit_gb": limitGB, + "in_limit_gb": c.TrafficInGB, + "out_limit_gb": c.TrafficOutGB, + "used_pct": usedPct, + "reset_date": c.TrafficResetDate, + } +} + +// ToJSON converts data to JSON bytes +func ToJSON(v interface{}) ([]byte, error) { + return json.MarshalIndent(v, "", " ") +} diff --git a/backend/internal/lxc/portmap.go b/backend/internal/lxc/portmap.go new file mode 100644 index 0000000..93cb1d3 --- /dev/null +++ b/backend/internal/lxc/portmap.go @@ -0,0 +1,196 @@ +package lxc + +import ( + "fmt" + "os/exec" + "strconv" + + "clicd/internal/config" +) + +// ApplyPortMappings applies iptables DNAT rules for a container's port mappings +func (m *Manager) ApplyPortMappings(id int) error { + c := config.FindContainer(id) + if c == nil { + return fmt.Errorf("container not found: %d", id) + } + if c.IP == "" { + return fmt.Errorf("container has no IP") + } + tag := clicdTag(id) + + EnsureForwardRules() + m.CleanPortMappings(id) + + for _, pm := range c.PortMappings { + cmd := exec.Command("iptables", + "-t", "nat", + "-I", "PREROUTING", "1", + "-p", pm.Protocol, + "--dport", fmt.Sprintf("%d", pm.HostPort), + "-j", "DNAT", + "--to-destination", fmt.Sprintf("%s:%d", c.IP, pm.ContainerPort), + "-m", "comment", "--comment", fmt.Sprintf("clicd-%s-%d", tag, pm.HostPort), + ) + output, err := cmd.CombinedOutput() + if err != nil { + fmt.Printf("Warning: failed to apply port mapping %d->%s:%d: %v, output: %s\n", + pm.HostPort, c.IP, pm.ContainerPort, err, string(output)) + continue + } + fmt.Printf("Port mapping: host:%d -> %s:%d\n", pm.HostPort, c.IP, pm.ContainerPort) + } + + if exec.Command("iptables", "-t", "nat", "-C", "POSTROUTING", "-s", "10.0.3.0/24", "-o", "eth+", "-j", "MASQUERADE").Run() != nil { + exec.Command("iptables", "-t", "nat", "-I", "POSTROUTING", "1", "-s", "10.0.3.0/24", "-o", "eth+", "-j", "MASQUERADE").Run() + } + + return nil +} + +func clicdTag(id int) string { return "c" + strconv.Itoa(id) } + +// EnsureForwardRules makes sure iptables FORWARD chain allows LXC bridge traffic +func EnsureForwardRules() { + rules := [][]string{ + {"-A", "FORWARD", "-i", "lxcbr0", "-j", "ACCEPT"}, + {"-A", "FORWARD", "-o", "lxcbr0", "-j", "ACCEPT"}, + {"-A", "FORWARD", "-i", "lxcbr0", "-o", "lxcbr0", "-j", "ACCEPT"}, + } + for _, args := range rules { + checkArgs := append([]string{"-C", "FORWARD"}, args[2:]...) + if exec.Command("iptables", checkArgs...).Run() != nil { + exec.Command("iptables", args...).Run() + } + } +} + +// CleanPortMappings removes all iptables rules for a container +func (m *Manager) CleanPortMappings(id int) error { + tag := clicdTag(id) + cmd := exec.Command("sh", "-c", + fmt.Sprintf("iptables -t nat -L PREROUTING -n --line-numbers 2>/dev/null | grep 'clicd-%s' | awk '{print $1}' | sort -rn | while read num; do iptables -t nat -D PREROUTING $num; done", tag)) + cmd.Run() + return nil +} + +// SetupDefaultPortMappings creates default port mappings +func SetupDefaultPortMappings(sshPort int) []config.PortMapping { + return []config.PortMapping{ + {ContainerPort: 22, HostPort: sshPort, Protocol: "tcp", Description: "SSH"}, + } +} + +// AddPortMapping adds a NAT rule to a container +func (m *Manager) AddPortMapping(id int, pm config.PortMapping) ([]config.PortMapping, error) { + c := config.FindContainer(id) + if c == nil { + return nil, fmt.Errorf("container not found: %d", id) + } + if c.PortMappingLimit > 0 && len(c.PortMappings) >= c.PortMappingLimit { + return nil, fmt.Errorf("port mapping quota exceeded: %d/%d", len(c.PortMappings), c.PortMappingLimit) + } + normalized, err := normalizePortMapping(c, -1, pm) + if err != nil { + return nil, err + } + c.PortMappings = append(c.PortMappings, normalized) + if err := persistAndReloadMappings(m, c); err != nil { + return nil, err + } + return c.PortMappings, nil +} + +// UpdatePortMapping updates an existing NAT rule +func (m *Manager) UpdatePortMapping(id int, index int, pm config.PortMapping) ([]config.PortMapping, error) { + c := config.FindContainer(id) + if c == nil { + return nil, fmt.Errorf("container not found: %d", id) + } + if index < 0 || index >= len(c.PortMappings) { + return nil, fmt.Errorf("invalid port mapping index: %d", index) + } + normalized, err := normalizePortMapping(c, index, pm) + if err != nil { + return nil, err + } + c.PortMappings[index] = normalized + if err := persistAndReloadMappings(m, c); err != nil { + return nil, err + } + return c.PortMappings, nil +} + +// DeletePortMapping removes a NAT rule +func (m *Manager) DeletePortMapping(id int, index int) ([]config.PortMapping, error) { + c := config.FindContainer(id) + if c == nil { + return nil, fmt.Errorf("container not found: %d", id) + } + if index < 0 || index >= len(c.PortMappings) { + return nil, fmt.Errorf("invalid port mapping index: %d", index) + } + if c.PortMappings[index].Description == "SSH" { + return nil, fmt.Errorf("SSH default mapping cannot be deleted") + } + c.PortMappings = append(c.PortMappings[:index], c.PortMappings[index+1:]...) + if err := persistAndReloadMappings(m, c); err != nil { + return nil, err + } + return c.PortMappings, nil +} + +func persistAndReloadMappings(m *Manager, c *config.Container) error { + config.SaveConfig() + if c.Status == "running" && c.IP != "" { + return m.ApplyPortMappings(c.ID) + } + return nil +} + +func normalizePortMapping(c *config.Container, skipIndex int, pm config.PortMapping) (config.PortMapping, error) { + if pm.ContainerPort < 1 || pm.ContainerPort > 65535 { + return pm, fmt.Errorf("container port must be 1-65535") + } + if pm.Protocol == "" { + pm.Protocol = "tcp" + } + if pm.Description == "" { + pm.Description = fmt.Sprintf("Port-%d", pm.ContainerPort) + } + if pm.HostPort <= 0 { + pm.HostPort = pm.ContainerPort + } + for i, existing := range c.PortMappings { + if i == skipIndex { + continue + } + if existing.HostPort == pm.HostPort && existing.Protocol == pm.Protocol { + return pm, fmt.Errorf("host port %d/%s already mapped", pm.HostPort, pm.Protocol) + } + } + return pm, nil +} + +func allocateDefaultEqualPorts(c *config.Container, count int) []int { + if count <= 0 { + return nil + } + used := map[int]bool{} + for _, pm := range c.PortMappings { + used[pm.HostPort] = true + used[pm.ContainerPort] = true + } + ports := make([]int, 0, count) + next := 20000 + for len(ports) < count { + if !used[next] { + ports = append(ports, next) + } + next++ + if next > 65535 || len(ports) >= count { + break + } + } + return ports +} diff --git a/backend/internal/lxc/templates.go b/backend/internal/lxc/templates.go new file mode 100644 index 0000000..bfe8a92 --- /dev/null +++ b/backend/internal/lxc/templates.go @@ -0,0 +1,74 @@ +package lxc + +// Template represents an LXC image template +type Template struct { + ID string `json:"id"` + Name string `json:"name"` + Distro string `json:"distro"` + Release string `json:"release"` + Arch string `json:"arch"` + Variant string `json:"variant"` + Description string `json:"description"` +} + +// GetTemplates returns available LXC image templates (only verified working ones) +func GetTemplates() []Template { + return []Template{ + { + ID: "ubuntu-noble", Name: "Ubuntu 24.04", + Distro: "ubuntu", Release: "noble", Arch: "amd64", + Description: "Ubuntu 24.04 LTS", + }, + { + ID: "ubuntu-jammy", Name: "Ubuntu 22.04", + Distro: "ubuntu", Release: "jammy", Arch: "amd64", + Description: "Ubuntu 22.04 LTS", + }, + { + ID: "debian-bookworm", Name: "Debian 12", + Distro: "debian", Release: "bookworm", Arch: "amd64", + Description: "Debian 12 (Bookworm)", + }, + { + ID: "debian-bullseye", Name: "Debian 11", + Distro: "debian", Release: "bullseye", Arch: "amd64", + Description: "Debian 11 (Bullseye)", + }, + { + ID: "alpine-3.21", Name: "Alpine 3.21", + Distro: "alpine", Release: "3.21", Arch: "amd64", + Description: "Alpine Linux 3.21", + }, + { + ID: "centos-9-stream", Name: "CentOS 9 Stream", + Distro: "centos", Release: "9-Stream", Arch: "amd64", + Description: "CentOS 9 Stream", + }, + { + ID: "archlinux-current", Name: "Arch Linux", + Distro: "archlinux", Release: "current", Arch: "amd64", Variant: "cloud", + Description: "Arch Linux (Rolling)", + }, + { + ID: "fedora-44", Name: "Fedora 44", + Distro: "fedora", Release: "44", Arch: "amd64", Variant: "cloud", + Description: "Fedora 44", + }, + { + ID: "rockylinux-10", Name: "Rocky Linux 10", + Distro: "rockylinux", Release: "10", Arch: "amd64", Variant: "cloud", + Description: "Rocky Linux 10", + }, + } +} + +// FindTemplate finds a template by ID +func FindTemplate(id string) *Template { + templates := GetTemplates() + for _, t := range templates { + if t.ID == id { + return &t + } + } + return nil +} diff --git a/backend/internal/server/embed.go b/backend/internal/server/embed.go new file mode 100644 index 0000000..a13333e --- /dev/null +++ b/backend/internal/server/embed.go @@ -0,0 +1,19 @@ +package server + +import ( + "embed" + "io/fs" + "net/http" +) + +//go:embed web/** +var embeddedWeb embed.FS + +// GetEmbeddedFS returns the embedded frontend file system +func GetEmbeddedFS() http.FileSystem { + sub, err := fs.Sub(embeddedWeb, "web") + if err != nil { + return http.Dir("web") + } + return http.FS(sub) +} diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go new file mode 100644 index 0000000..c31f084 --- /dev/null +++ b/backend/internal/server/server.go @@ -0,0 +1,138 @@ +package server + +import ( + "fmt" + "log" + "net/http" + "strings" + "time" + + "clicd/internal/api" + "clicd/internal/config" + "clicd/internal/lxc" +) + +// webFS holds embedded frontend files +var webFS http.FileSystem + +// corsMiddleware adds CORS headers +func corsMiddleware(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") + w.Header().Set("Access-Control-Allow-Credentials", "true") + + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusOK) + return + } + + next(w, r) + } +} + +// setupRoutes configures API and static routes +func setupRoutes(mux *http.ServeMux) { + // API routes + mux.HandleFunc("/api/login", corsMiddleware(api.HandleLogin)) + mux.HandleFunc("/api/check-auth", corsMiddleware(api.AuthMiddleware(api.HandleCheckAuth))) + mux.HandleFunc("/api/change-password", corsMiddleware(api.AdminMiddleware(api.HandleAdminPasswordChange))) + mux.HandleFunc("/api/change-username", corsMiddleware(api.AdminMiddleware(api.HandleAdminUsernameChange))) + mux.HandleFunc("/api/login-logs", corsMiddleware(api.AdminMiddleware(api.HandleLoginLogs))) + mux.HandleFunc("/api/containers", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleContainers)))) + mux.HandleFunc("/api/containers/", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleSingleContainer)))) + mux.HandleFunc("/api/templates", corsMiddleware(api.AuthMiddleware(api.HandleTemplates))) + mux.HandleFunc("/api/images", corsMiddleware(api.AdminMiddleware(api.HandleImages))) + mux.HandleFunc("/api/images/download", corsMiddleware(api.AdminMiddleware(api.HandleImageDownload))) + mux.HandleFunc("/api/images/delete", corsMiddleware(api.AdminMiddleware(api.HandleImageDelete))) + mux.HandleFunc("/api/images/toggle", corsMiddleware(api.AdminMiddleware(api.HandleImageToggle))) + mux.HandleFunc("/api/images/enabled", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleEnabledImages)))) + mux.HandleFunc("/api/dashboard", corsMiddleware(api.AdminMiddleware(api.HandleDashboard))) + mux.HandleFunc("/api/host-info", corsMiddleware(api.AdminMiddleware(api.HandleHostInfo))) + mux.HandleFunc("/api/ipv6/status", corsMiddleware(api.AdminMiddleware(api.HandleIPv6Status))) + mux.HandleFunc("/api/oversell", corsMiddleware(api.AdminMiddleware(api.HandleOversell))) + mux.HandleFunc("/api/oversell/status", corsMiddleware(api.AdminMiddleware(api.HandleOversellStatus))) + mux.HandleFunc("/api/oversell/reclaim", corsMiddleware(api.AdminMiddleware(api.HandleOversellReclaim))) + mux.HandleFunc("/api/tasks", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleTasks)))) + mux.HandleFunc("/api/tasks/", corsMiddleware(api.AuthMiddleware(api.AdminMiddleware(api.HandleTaskDelete)))) + mux.HandleFunc("/api/batch-create", corsMiddleware(api.AdminMiddleware(api.HandleBatchCreate))) + mux.HandleFunc("/api/batch-action", corsMiddleware(api.AdminMiddleware(api.HandleBatchAction))) + mux.HandleFunc("/api/sub-user/create", corsMiddleware(api.AdminMiddleware(api.HandleSubUserCreate))) + mux.HandleFunc("/api/sub-user/login", corsMiddleware(api.HandleSubUserLogin)) + mux.HandleFunc("/api/sub-user/access", corsMiddleware(api.HandleSubUserAccessCode)) + mux.HandleFunc("/api/audit-logs", corsMiddleware(api.AdminMiddleware(api.HandleAuditLogs))) + mux.HandleFunc("/api/security/alerts", corsMiddleware(api.AdminMiddleware(api.HandleSecurityAlerts))) + mux.HandleFunc("/api/security/check", corsMiddleware(api.AdminMiddleware(api.HandleSecurityCheck))) + mux.HandleFunc("/api/security/logs", corsMiddleware(api.AdminMiddleware(api.HandleSecurityLogs))) + mux.HandleFunc("/api/security/summary", corsMiddleware(api.AdminMiddleware(api.HandleContainerSecuritySummary))) + mux.HandleFunc("/api/ssh-ticket", corsMiddleware(api.AuthMiddleware(api.HandleWebSSHTicket))) + mux.HandleFunc("/api/ssh", api.HandleWebSSH) // WebSocket + + // API Key management + mux.HandleFunc("/api/api-keys", corsMiddleware(api.AdminMiddleware(api.HandleApiKeys))) + mux.HandleFunc("/api/api-keys/", corsMiddleware(api.AdminMiddleware(api.HandleApiKeyDelete))) + + // Static files + if webFS != nil { + fs := http.FileServer(webFS) + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + // API routes already handled above + if strings.HasPrefix(r.URL.Path, "/api/") { + http.NotFound(w, r) + return + } + // Try to serve file + path := r.URL.Path + f, err := webFS.Open(path) + if err != nil { + // SPA fallback: serve index.html + indexFile, err := webFS.Open("index.html") + if err != nil { + http.Error(w, "Not found", http.StatusNotFound) + return + } + defer indexFile.Close() + stat, _ := indexFile.Stat() + http.ServeContent(w, r, "index.html", stat.ModTime(), indexFile) + return + } + defer f.Close() + fs.ServeHTTP(w, r) + }) + } +} + +// Run starts the HTTP server +func Run() error { + // Use embedded frontend files + webFS = GetEmbeddedFS() + startExpiryMonitor() + + mux := http.NewServeMux() + setupRoutes(mux) + + addr := fmt.Sprintf("0.0.0.0:%d", config.AppConfig.Port) + log.Printf("CLICD Web Server starting on http://0.0.0.0:%d", config.AppConfig.Port) + log.Printf("Admin user: %s", config.AppConfig.AdminUser) + + server := &http.Server{ + Addr: addr, + Handler: mux, + } + + return server.ListenAndServe() +} + +func startExpiryMonitor() { + manager := lxc.NewManager() + go func() { + manager.StopExpiredContainers(time.Now()) + + ticker := time.NewTicker(time.Minute) + defer ticker.Stop() + for now := range ticker.C { + manager.StopExpiredContainers(now) + } + }() +} diff --git a/backend/internal/server/web/.gitkeep b/backend/internal/server/web/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/internal/server/web/.gitkeep @@ -0,0 +1 @@ + diff --git a/backend/main.go b/backend/main.go new file mode 100644 index 0000000..1894f4b --- /dev/null +++ b/backend/main.go @@ -0,0 +1,102 @@ +package main + +import ( + "fmt" + "os" + "os/exec" + "strings" + + "clicd/internal/api" + "clicd/internal/cli" + "clicd/internal/config" + "clicd/internal/lxc" + "clicd/internal/server" + + "golang.org/x/term" +) + +func main() { + isTerminal := term.IsTerminal(int(os.Stdin.Fd())) + + isServerMode := false + isCliMode := false + noWebAutostart := false + for _, arg := range os.Args[1:] { + if arg == "server" || arg == "-s" || arg == "--server" { + isServerMode = true + } + if arg == "cli" || arg == "-c" || arg == "--cli" { + isCliMode = true + } + if arg == "--no-web" || arg == "--cli-only" { + noWebAutostart = true + isCliMode = true + } + } + + // Initialize config + cfg, err := config.InitConfig() + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to initialize config: %v\n", err) + os.Exit(1) + } + _ = cfg + + if isServerMode || (!isTerminal && !isCliMode) { + // Restore persisted state + api.RestoreTasks() + api.RestoreLoginLogs() + + // Start security scanner + api.InitScanner() + + // Ensure iptables FORWARD rules allow LXC traffic + lxc.EnsureForwardRules() + + // Start expiry scanner (stops expired containers every 30s) + manager := lxc.NewManager() + manager.StartExpiryScanner() + + // Start usage monitor (computes CPU/network/disk rates every 5s) + manager.StartUsageMonitor() + + // Clean up stale container configs (LXC dir was deleted but config remains) + config.CleanStaleContainers() + + // Pre-warm SSH for containers already running after host boot or service restart. + manager.StartSSHWarmupScanner() + + // Run in server mode (frontend embedded in binary) + if err := server.Run(); err != nil { + fmt.Fprintf(os.Stderr, "Server error: %v\n", err) + os.Exit(1) + } + } else { + // CLI mode normally keeps the web panel available. Use --no-web to avoid + // starting the systemd web service on locked-down hosts. + if !noWebAutostart && !isWebPanelSystemdRunning() { + startWebPanelSystemd() + } + + // Run CLI interface + cli.Run() + } +} + +func isWebPanelSystemdRunning() bool { + cmd := exec.Command("systemctl", "is-active", "clicd") + output, err := cmd.Output() + if err != nil { + return false + } + return strings.TrimSpace(string(output)) == "active" +} + +func startWebPanelSystemd() { + cmd := exec.Command("systemctl", "start", "clicd") + if err := cmd.Run(); err != nil { + fmt.Fprintf(os.Stderr, "警告: 自动启动 Web 面板失败: %v\n", err) + } else { + fmt.Println("Web 面板已自动启动") + } +} diff --git a/build.sh b/build.sh new file mode 100644 index 0000000..be90680 --- /dev/null +++ b/build.sh @@ -0,0 +1,76 @@ +#!/bin/bash +set -e + +# CLICD Build Script +# Builds frontend and backend into a single deployable package + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +BUILD_DIR="$SCRIPT_DIR/build" +FRONTEND_DIR="$SCRIPT_DIR/frontend" +BACKEND_DIR="$SCRIPT_DIR/backend" +WEB_DIR="$SCRIPT_DIR/web" +EMBED_WEB_DIR="$BACKEND_DIR/internal/server/web" + +echo "=====================================" +echo " CLICD Build Script" +echo "=====================================" + +# Clean previous build +rm -rf "$BUILD_DIR" +rm -rf "$WEB_DIR" +rm -rf "$EMBED_WEB_DIR" +mkdir -p "$BUILD_DIR" +mkdir -p "$WEB_DIR" +mkdir -p "$EMBED_WEB_DIR" +touch "$EMBED_WEB_DIR/.gitkeep" + +# Step 1: Build frontend +echo "" +echo "[1/3] Building frontend..." +cd "$FRONTEND_DIR" + +if [ ! -d "node_modules" ]; then + echo "Installing frontend dependencies..." + npm install +fi + +npm run build + +# Copy frontend build to web directory (for Go embed) +cp -r dist/* "$WEB_DIR/" +# Keep the Go embed directory in sync with the frontend build. +cp -r dist/* "$EMBED_WEB_DIR/" +touch "$EMBED_WEB_DIR/.gitkeep" +echo "Frontend built successfully" + +# Step 2: Build Go backend +echo "" +echo "[2/3] Building Go backend..." +cd "$BACKEND_DIR" + +go mod tidy +go mod download + +# Build for Linux amd64 +GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -ldflags="-s -w" -o "$BUILD_DIR/clicd" . + +echo "Go backend built successfully" + +# Step 3: Package +echo "" +echo "[3/3] Packaging..." +cp -r "$WEB_DIR" "$BUILD_DIR/web" +cp "$SCRIPT_DIR/install.sh" "$BUILD_DIR/install.sh" 2>/dev/null || true +chmod +x "$BUILD_DIR/clicd" + +echo "" +echo "=====================================" +echo " Build Complete!" +echo "=====================================" +echo " Output: $BUILD_DIR/clicd" +echo " Web: $BUILD_DIR/web/" +echo "" +echo " To deploy:" +echo " 1. Copy build/ directory to server" +echo " 2. Run: ./clicd server" +echo "=====================================" diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..8942472 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + CLICD - LXC Container Manager + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..4d87a5b --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,3023 @@ +{ + "name": "clicd-frontend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "clicd-frontend", + "version": "1.0.0", + "dependencies": { + "@xterm/addon-fit": "^0.11.0", + "@xterm/xterm": "^6.0.0", + "axios": "^1.7.7", + "lucide-react": "^0.454.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.28.0" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.15", + "typescript": "^5.6.3", + "vite": "^5.4.11" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.3", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz", + "integrity": "sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.61.0.tgz", + "integrity": "sha512-dnxczajOqt0gesZlN5pGQ1s1imQVrsmCw5G2Ci4oM+0WvNz3pyRnlWrT7McoZIb8VlFwCawdmbWRmxRn7HI+VQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.61.0.tgz", + "integrity": "sha512-Bp3JpGP00Vu3f238ivRrjf7z3xSzVPXqCmaJYA9t2c+c8vKYvOzmXF7LkkeUalTEGd6cZcSWe+PFIP3Vy48fRg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.61.0.tgz", + "integrity": "sha512-zaYIpr670mUmmZ1tVzUFplbQbG7h3Gugx3L5FoqhsC2m/YnLlR1a7zVLmXNPy+iY1tFPEbNG+HHBXZGyId0G5w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.61.0.tgz", + "integrity": "sha512-+P49fvkv2dSoeevUW+lgZ/I2JHSsJCK1Lyjj7Cu6E4UHG4tS9XIefzIjo5qhgELjAclnen1rLzK2PMKJdo+Dyg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.61.0.tgz", + "integrity": "sha512-l3FAAOyKJXH2ea6KNFN+MMgC/rnE94YGLXs2ehYqDcCoHt1DpvgWX75BhUJxN38XojP7Ul+4H8PRn7EdyqSDrw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.61.0.tgz", + "integrity": "sha512-VokPN3TSctKj65cyCNPaUh4vMFA8awxOot/0sp+4J7ZlNRKQEhXhawqPwajoi8H5ZFt61i0ugZJuTKXBjGJ17Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.61.0.tgz", + "integrity": "sha512-DxH0P3wxm+Yzs/p3zrk9dw1rURu8p0Nv5+MRK/L7OtnLNg5rLZraSBFZ8iUXOd9f2BlhJyEpIZUH/emjq4UJ4g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.61.0.tgz", + "integrity": "sha512-T6ZvMNe84kAz6TBWHC7hGAoEtzP1LWYw/AqayGWEF6uISt3Abk/st06LqRD9THd7Xz3NxzurUpzAuEAUbZf+nw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.61.0.tgz", + "integrity": "sha512-q/4hzvQkDs8b4jIBab1pnLiiM0ayTZsN2amBFPDzuyZxjEd4wDwx0UJFYM3cOZzSf5Kw8fnWSprJzIBMkcR44Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.61.0.tgz", + "integrity": "sha512-vvYWX3akdEAY6km+9wAqFDnk6pQsbJKVnj7xawcvs/+fdlYBGp+U+Qq/lLfpIxYIZvZLHMAKD9HLdacSx/r3dw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.61.0.tgz", + "integrity": "sha512-DePa5cqOxDP/Zp0VOXpeWaGew5iIv5DXp9NYbzkX5PFQyWVX9184WCTh3hvr/7lhXo8ZVlbFLkz8+o/q1dU6gA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.61.0.tgz", + "integrity": "sha512-LV8aWMB8UChglMCEzs7RkN0GsH29RJaLLqwm9fCIjlqwxQTiWAqNcc7wjBkH31hV0PU/yVxGYvrYsgfea2qw6g==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.61.0.tgz", + "integrity": "sha512-QoNSnwQtaeNu5grdBbsL0tt1uyl5EnS8DA8Mr3nluMXbhdQNyhN+G4tBax7VCdxLKj8YJ0/4OO9Ho84jMnJtKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.61.0.tgz", + "integrity": "sha512-/zZp5MKapIIApE8trN8qLGNSiRN9TUoaUZ1cmVu4XnVdd5LQLOXTtyi+vtfUbNnT3iyjzpPqYeKXmvJ+gJGYWw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.61.0.tgz", + "integrity": "sha512-RbrzcD3aJ1k3UbtMRRBNwojdVVyXjuVAFTfn/xPa6EEl6GE9Sm/akPgFTb9aAC9pMKGJ6CtWxaGrqWcabH+ySg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.61.0.tgz", + "integrity": "sha512-ZF+onDsBso8PJf1XaG9lB+O9RnBpKGnY6OrzC4CSHrtC1jb6jWLTKK4bRqdoCXHd22gyr2hiYmEAm8Wns/BOCw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.61.0.tgz", + "integrity": "sha512-Atk0aSIk5Zx2Wuh9dgRQgLP0Koc8hOeYpbWryMXyk8G8/HmPkwPPkMqIIDhrXHHYqfUzSJA/I7IWSBv8xSmRBA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.61.0.tgz", + "integrity": "sha512-0uMOcf3eZ5K+K4cYHkdxShFMPlPXCOdfDFEFn9dNYAEEd2cVvmOfH7zFgRVoDgmtQ1m9k5q7qfrHzyMAubKYUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.61.0.tgz", + "integrity": "sha512-mvFtE4A/t/7hRJ7X8Ozmu8FsIkAUat2nzl12pgU337BRmq87AQUJztwHz2Zv5/tjo9/C95E66CK03SI/ToEDJw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.61.0.tgz", + "integrity": "sha512-z9b9+aTxvt8n2rNltMPvyaUfB8NJ+CVyOrGK/MdIKHx7B+lXmZpm/XbRsU7Rpf3fRqJ2uS6mBJiJveCtq8LHDg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.61.0.tgz", + "integrity": "sha512-jXaXFqKMehsOc+g8R6oo33RRC6w07G9jDBxAE5eAKX7mOcCbZloYIPNhfG9Wl+P9O9IWHFO4OJgPi1Ml2qkt7w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.61.0.tgz", + "integrity": "sha512-OXNWVFocS2IA4+QplhTZZ2a+8hPZR7T8KuozsNmJKK8y7cp83StHvGksfHzPG3wczWTczyWHVQuqeiTUbjiyBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.61.0.tgz", + "integrity": "sha512-AlAbNtBO637LxSldqV43z0FfXoGfl2TW1DgAg/bs7aQswFbDewz2SJm3BUhiGfbOVtW571xbc9p+REdxhyN/Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.61.0.tgz", + "integrity": "sha512-QRSrQXyJ1M4tjNXdR0/G/IgV6lzfQQJYBjlWIEYkY2Xs86DRl/iEpQ4blMDjJxSl7n19eDKKXMg0AmuBVYy8pQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.61.0.tgz", + "integrity": "sha512-tkuFxhvKO/HlGd0VsINF6vHSYH8AF8W0TcNxKDK6JZmrehngFj78pToc8iemtnvwilDjs2G/qSzYFhe9U8q+fw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.30", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.30.tgz", + "integrity": "sha512-3ek6mwJL5/VBewBcY4S66cqlCtK3qi4WIq37Z0m/NHw1hjhI7274Mx1qz/+ggSzyBCOEf7eHjBN6INjPAWYfYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@xterm/addon-fit": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz", + "integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==", + "license": "MIT" + }, + "node_modules/@xterm/xterm": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz", + "integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==", + "license": "MIT", + "workspaces": [ + "addons/*" + ] + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz", + "integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.2", + "caniuse-lite": "^1.0.30001787", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/axios": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.17.0.tgz", + "integrity": "sha512-J8SwNxprqqpbfenehxWYXE7CW+wM1BB4w3+N+g+/Wx40xM4rsLrfPmHHxSWIxJLYDgSY/HqlFPIYb2/S3rxafw==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.33", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.33.tgz", + "integrity": "sha512-bA6+tcSLpz2tIEdDXZPpPTIuxBcC4+w6SieaYyfigIa4h8GlFxbA17v22Vx3JUtuZQj9SgOsnbK+aTBzyDyEuw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001793", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", + "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.366", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.366.tgz", + "integrity": "sha512-OlRuhb688YTCzzU3gXPLn6nGyd+F+53INE1qaKKlu6kETErE8FYsyDh0XqXEU+uBRn0MpCzz2vfNwORhkap8qg==", + "dev": true, + "license": "ISC" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.454.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.454.0.tgz", + "integrity": "sha512-hw7zMDwykCLnEzgncEEjHeA6+45aeEzRYuKHuyRSOPkhko+J3ySGjGIzu+mmMfDFG1vazHepMaYFYHbTFAZAAQ==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.47", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz", + "integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz", + "integrity": "sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.4.tgz", + "integrity": "sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.3", + "react-router": "6.30.4" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.0.tgz", + "integrity": "sha512-T9mWdbWfQtp0B5lv/HX+wrhYsmXRlcWnXXmJbXqKJhlRaoS6KMhq0gpyzW4UJfclcxrEdLnTgjT2NjruLONu0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.61.0", + "@rollup/rollup-android-arm64": "4.61.0", + "@rollup/rollup-darwin-arm64": "4.61.0", + "@rollup/rollup-darwin-x64": "4.61.0", + "@rollup/rollup-freebsd-arm64": "4.61.0", + "@rollup/rollup-freebsd-x64": "4.61.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.61.0", + "@rollup/rollup-linux-arm-musleabihf": "4.61.0", + "@rollup/rollup-linux-arm64-gnu": "4.61.0", + "@rollup/rollup-linux-arm64-musl": "4.61.0", + "@rollup/rollup-linux-loong64-gnu": "4.61.0", + "@rollup/rollup-linux-loong64-musl": "4.61.0", + "@rollup/rollup-linux-ppc64-gnu": "4.61.0", + "@rollup/rollup-linux-ppc64-musl": "4.61.0", + "@rollup/rollup-linux-riscv64-gnu": "4.61.0", + "@rollup/rollup-linux-riscv64-musl": "4.61.0", + "@rollup/rollup-linux-s390x-gnu": "4.61.0", + "@rollup/rollup-linux-x64-gnu": "4.61.0", + "@rollup/rollup-linux-x64-musl": "4.61.0", + "@rollup/rollup-openbsd-x64": "4.61.0", + "@rollup/rollup-openharmony-arm64": "4.61.0", + "@rollup/rollup-win32-arm64-msvc": "4.61.0", + "@rollup/rollup-win32-ia32-msvc": "4.61.0", + "@rollup/rollup-win32-x64-gnu": "4.61.0", + "@rollup/rollup-win32-x64-msvc": "4.61.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..1f6650e --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,30 @@ +{ + "name": "clicd-frontend", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@xterm/addon-fit": "^0.11.0", + "@xterm/xterm": "^6.0.0", + "axios": "^1.7.7", + "lucide-react": "^0.454.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.28.0" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.15", + "typescript": "^5.6.3", + "vite": "^5.4.11" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..b563b6a --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1 @@ + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..37a4217 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,69 @@ +import { Routes, Route, Navigate } from 'react-router-dom' +import { useAuth } from './contexts/AuthContext' +import Login from './pages/Login' +import Dashboard from './pages/Dashboard' +import Containers from './pages/Containers' +import ContainerDetail from './pages/ContainerDetail' +import Oversell from './pages/Oversell' +import Security from './pages/Security' +import AuditLogs from './pages/AuditLogs' +import ApiIntegration from './pages/ApiIntegration' +import Settings from './pages/Settings' +import ImageManagement from './pages/ImageManagement' +import Layout from './components/Layout' + +function ProtectedRoute({ children }: { children: React.ReactNode }) { + const { isAuthenticated, isLoading } = useAuth() + + if (isLoading) { + return ( +
+
+
+ ) + } + + if (!isAuthenticated) { + return + } + + return <>{children} +} + +function HomeRoute() { + const { isSubUser, containerIdentifiers } = useAuth() + if (isSubUser) { + const firstContainer = containerIdentifiers[0] + return + } + return +} + +function App() { + return ( + + } /> + + + + } + > + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + } /> + + ) +} + +export default App diff --git a/frontend/src/components/AppIcon.tsx b/frontend/src/components/AppIcon.tsx new file mode 100644 index 0000000..3647cc4 --- /dev/null +++ b/frontend/src/components/AppIcon.tsx @@ -0,0 +1,14 @@ +type AppIconProps = { + className?: string +} + +export default function AppIcon({ className = 'w-6 h-6' }: AppIconProps) { + return ( + + ) +} diff --git a/frontend/src/components/ContainerCard.tsx b/frontend/src/components/ContainerCard.tsx new file mode 100644 index 0000000..f5ce855 --- /dev/null +++ b/frontend/src/components/ContainerCard.tsx @@ -0,0 +1,142 @@ +import { useNavigate } from 'react-router-dom' +import { + Server, + Cpu, + HardDrive, + MemoryStick, + Globe, + Play, + Square, + RotateCcw, + Trash2, +} from 'lucide-react' +import { Container, startContainer, stopContainer, restartContainer, deleteContainer } from '../services/api' + +interface ContainerCardProps { + container: Container + onRefresh: () => void +} + +export default function ContainerCard({ container, onRefresh }: ContainerCardProps) { + const navigate = useNavigate() + const containerIdentifier = container.uuid || container.id + + const handleAction = async (action: string) => { + try { + switch (action) { + case 'start': + await startContainer(containerIdentifier) + break + case 'stop': + await stopContainer(containerIdentifier) + break + case 'restart': + await restartContainer(containerIdentifier) + break + case 'delete': + if (window.confirm(`确定要删除容器 ${container.name} 吗?此操作不可撤销。`)) { + await deleteContainer(containerIdentifier) + } else { + return + } + break + } + onRefresh() + } catch (err) { + console.error('Action failed:', err) + alert('操作失败') + } + } + + const statusColor = container.status === 'running' ? 'bg-green-500' : 'bg-red-500' + const statusText = container.status === 'running' ? '运行中' : '已停止' + + return ( +
+ {/* Header */} +
+
+
+ +
+
+ +
+ + {statusText} +
+
+
+
+ + {/* Specs */} +
+
+ + {container.vcpu} vCPU +
+
+ + {container.ram_mb} MB +
+
+ + {container.disk_gb} GB +
+
+ + {container.network_bw_mbps} Mbps +
+
+ + {container.ip && ( +
+ IP: {container.ip} +
+ )} + + {/* Actions */} +
+ {container.status !== 'running' ? ( + + ) : ( + <> + + + + )} +
+ +
+
+ ) +} diff --git a/frontend/src/components/CreateContainerModal.tsx b/frontend/src/components/CreateContainerModal.tsx new file mode 100644 index 0000000..675ce06 --- /dev/null +++ b/frontend/src/components/CreateContainerModal.tsx @@ -0,0 +1,342 @@ +import { useEffect, useMemo, useState, type ReactNode } from 'react' +import { CalendarClock, X } from 'lucide-react' +import { batchCreate, getIPv6Status, getEnabledImages, getHostInfo, CreateContainerRequest, HostInfo, IPv6Status, Template } from '../services/api' +import { useDialog } from './Dialog' + +interface CreateContainerModalProps { + isOpen: boolean + onClose: () => void + onSuccess: (containers: CreateContainerRequest[]) => void | Promise +} + +const defaultForm: CreateContainerRequest = { + name: '', + template_id: '', + vcpu: 1, + cpu_percent: 100, + ram_mb: 512, + disk_gb: 10, + network_bw_mbps: 0, + monthly_traffic_gb: 0, + traffic_mode: 'total', + traffic_in_gb: 0, + traffic_out_gb: 0, + io_speed_mbps: 0, + extra_ports: [], + port_mapping_count: 2, + assign_ipv6: false, + expires_at: '', +} + +export default function CreateContainerModal({ isOpen, onClose, onSuccess }: CreateContainerModalProps) { + const dialog = useDialog() + const [templates, setTemplates] = useState([]) + const [loading, setLoading] = useState(false) + const [batchCount, setBatchCount] = useState(1) + const [form, setForm] = useState(defaultForm) + const [hostInfo, setHostInfo] = useState(null) + const [ipv6Status, setIPv6Status] = useState(null) + + useEffect(() => { + if (!isOpen) return + + getEnabledImages() + .then((res) => { + const data = res.data.data || [] + setTemplates(data) + if (data.length > 0) { + setForm((prev) => ({ ...prev, template_id: prev.template_id || data[0].id })) + } + }) + .catch(console.error) + + getIPv6Status() + .then((res) => { + const status = res.data.data || null + setIPv6Status(status) + if (!status?.available) { + setForm((prev) => ({ ...prev, assign_ipv6: false })) + } + }) + .catch(() => { + setIPv6Status({ available: false, reachable: false, reason: 'IPv6 status check failed', prefixes: [] }) + setForm((prev) => ({ ...prev, assign_ipv6: false })) + }) + + getHostInfo() + .then((res) => setHostInfo(res.data.data || null)) + .catch(() => setHostInfo(null)) + }, [isOpen]) + + const ipv6Available = !!ipv6Status?.available + const ipv6Prefix = ipv6Status?.prefixes?.[0]?.prefix || '' + const maxVCPU = hostInfo?.cpu.cores || 64 + const maxRAMMB = hostInfo?.ram.total_mb ? Number(hostInfo.ram.total_mb) : undefined + const maxDiskGB = hostInfo?.disk.total_gb ? Math.max(1, Math.floor(hostInfo.disk.total_gb)) : undefined + + const autoPorts = useMemo(() => { + const count = Math.max(2, form.port_mapping_count) + return Array.from({ length: count - 1 }, (_, index) => 22002 + index) + }, [form.port_mapping_count]) + + // SSH port preview (will be allocated sequentially, starting around 22000+) + const sshPortPreview = 22000 + + const handleSubmit = async () => { + if (!form.name || !form.template_id) { + dialog.alert('提示', '请填写容器名称并选择系统模板') + return + } + + const boundedForm = clampCreateForm(form, maxVCPU, maxRAMMB, maxDiskGB) + + // Build batch of containers + const containers: CreateContainerRequest[] = [] + for (let i = 0; i < batchCount; i++) { + const name = batchCount > 1 ? `${boundedForm.name}-${i + 1}` : boundedForm.name + containers.push({ ...boundedForm, name, port_mapping_count: Math.max(2, boundedForm.port_mapping_count || 2), extra_ports: [] }) + } + + setLoading(true) + try { + await batchCreate(containers) + await onSuccess(containers) + onClose() + setBatchCount(1) + setForm({ ...defaultForm, template_id: templates[0]?.id || '' }) + } catch (err: unknown) { + const error = err as { response?: { data?: { message?: string } } } + dialog.alert('创建失败', error.response?.data?.message || '请稍后重试') + } finally { + setLoading(false) + } + } + + if (!isOpen) return null + + return ( +
+
+
+

创建新容器

+ +
+ +
+
+ + setForm({ ...form, name: event.target.value })} + className={inputClass} + placeholder="my-container" + required + /> + + + setBatchCount(Math.max(1, value || 1))} /> + +
+ {batchCount > 1 &&

将创建 {batchCount} 个容器:{form.name}-1 至 {form.name}-{batchCount}

} + + + {templates.length === 0 ? ( +
+ 暂无可用的系统镜像,请先在「镜像管理」中下载镜像模板。 +
+ ) : ( + + )} +
+ + + +
+ + setForm({ ...form, vcpu: clampVCPU(value, maxVCPU) })} /> + + + setForm({ ...form, ram_mb: clampInt(value, 128, maxRAMMB, 512) })} /> + +
+ +
+ + setForm({ ...form, disk_gb: clampInt(value, 1, maxDiskGB, 10) })} /> + + + setForm({ ...form, network_bw_mbps: value })} /> + + + setForm({ ...form, io_speed_mbps: value })} /> + +
+ + {/* Traffic control */} +
+
+ + +
+ {form.traffic_mode === 'total' ? ( +
+ setForm({ ...form, monthly_traffic_gb: value })} /> + GB (0=不限制) +
+ ) : ( +
+ + setForm({ ...form, traffic_in_gb: value || 0 })} /> + + + setForm({ ...form, traffic_out_gb: value || 0 })} /> + +
+ )} +
+ + + setForm({ ...form, port_mapping_count: Math.max(2, value || 2) })} + /> +
+ + SSH: {sshPortPreview} -> 22 + + {autoPorts.map((port) => ( + + {port} -> {port} + + ))} +
+
+ + +
+ + setForm({ ...form, expires_at: event.target.value })} + min={new Date().toISOString().slice(0, 10)} + className={`${inputClass} pl-10`} + /> +
+

不选择则长期有效;选择日期后,到期会自动关机。

+
+
+ +
+ + +
+
+
+ ) +} + +function Field({ label, children }: { label: string; children: ReactNode }) { + return ( +
+ + {children} +
+ ) +} + +function NumberInput({ + value, + min, + max, + step, + onChange, +}: { + value: number + min?: number + max?: number + step?: number + onChange: (value: number) => void +}) { + return ( + { + const raw = event.target.value + const value = step && !Number.isInteger(step) ? parseFloat(raw) : parseInt(raw, 10) + onChange(value) + }} + className={inputClass} + /> + ) +} + +function clampCreateForm(form: CreateContainerRequest, maxVCPU: number, maxRAMMB?: number, maxDiskGB?: number): CreateContainerRequest { + return { + ...form, + vcpu: clampVCPU(form.vcpu, maxVCPU), + ram_mb: clampInt(form.ram_mb, 128, maxRAMMB, 512), + disk_gb: clampInt(form.disk_gb, 1, maxDiskGB, 10), + } +} + +function clampVCPU(value: number, max: number) { + const rounded = Math.round((Number.isFinite(value) ? value : 1) * 4) / 4 + return Number(Math.min(Math.max(rounded, 0.25), max).toFixed(2)) +} + +function clampInt(value: number, min: number, max?: number, fallback = min) { + const next = Math.round(Number.isFinite(value) ? value : fallback) + return Math.min(Math.max(next, min), max ?? next) +} + +const inputClass = + 'w-full px-3 py-2 border border-gray-300 rounded-md text-sm text-black bg-white focus:outline-none focus:ring-2 focus:ring-black focus:border-black' diff --git a/frontend/src/components/Dialog.tsx b/frontend/src/components/Dialog.tsx new file mode 100644 index 0000000..f5a6f37 --- /dev/null +++ b/frontend/src/components/Dialog.tsx @@ -0,0 +1,94 @@ +import { useState, useCallback, createContext, useContext, ReactNode } from 'react' +import { AlertTriangle, CheckCircle, X } from 'lucide-react' + +type DialogType = 'confirm' | 'alert' + +interface DialogState { + open: boolean + type: DialogType + title: string + message: string + resolve?: (value: boolean) => void +} + +interface DialogContextType { + confirm: (title: string, message: string) => Promise + alert: (title: string, message: string) => Promise +} + +const DialogContext = createContext(undefined) + +export function DialogProvider({ children }: { children: ReactNode }) { + const [dialog, setDialog] = useState({ open: false, type: 'alert', title: '', message: '' }) + + const confirm = useCallback((title: string, message: string) => { + return new Promise((resolve) => { + setDialog({ open: true, type: 'confirm', title, message, resolve }) + }) + }, []) + + const alert = useCallback((title: string, message: string) => { + return new Promise((resolve) => { + setDialog({ open: true, type: 'alert', title, message, resolve: () => resolve() }) + }) + }, []) + + const close = (result: boolean) => { + dialog.resolve?.(result) + setDialog({ open: false, type: 'alert', title: '', message: '' }) + } + + return ( + + {children} + {dialog.open && ( +
+
+
+
+ {dialog.type === 'confirm' ? : } +
+

{dialog.title}

+ {dialog.type === 'alert' && ( + + )} +
+
+

{dialog.message}

+
+
+ {dialog.type === 'confirm' && ( + + )} + +
+
+
+ )} +
+ ) +} + +export function useDialog() { + const ctx = useContext(DialogContext) + if (!ctx) throw new Error('useDialog must be used within DialogProvider') + return ctx +} diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx new file mode 100644 index 0000000..002ce71 --- /dev/null +++ b/frontend/src/components/Layout.tsx @@ -0,0 +1,18 @@ +import { Outlet } from 'react-router-dom' +import Sidebar from './Sidebar' +import { useState } from 'react' + +export default function Layout() { + const [sidebarCollapsed, setSidebarCollapsed] = useState(false) + + return ( +
+ setSidebarCollapsed(!sidebarCollapsed)} /> +
+
+ +
+
+
+ ) +} diff --git a/frontend/src/components/ResourceStatsPanel.tsx b/frontend/src/components/ResourceStatsPanel.tsx new file mode 100644 index 0000000..6e461c7 --- /dev/null +++ b/frontend/src/components/ResourceStatsPanel.tsx @@ -0,0 +1,224 @@ +import { ReactNode } from 'react' +import { RefreshCw } from 'lucide-react' + +export type StatsRangeKey = '30m' | '1h' | '1d' | '1w' + +export type ChartPoint = { + ts: number + value: number +} + +export type ResourceChartConfig = { + title: string + icon: ReactNode + points: ChartPoint[] + current: number + detail?: string + max?: number + unitLabel?: string + formatValue: (value: number) => string +} + +const rangeLabels: Record = { + '30m': '30分钟', + '1h': '1小时', + '1d': '1天', + '1w': '1周', +} + +export const statsRanges: Record = { + '30m': 30 * 60 * 1000, + '1h': 60 * 60 * 1000, + '1d': 24 * 60 * 60 * 1000, + '1w': 7 * 24 * 60 * 60 * 1000, +} + +export default function ResourceStatsPanel({ + range, + onRangeChange, + onRefresh, + charts, +}: { + range: StatsRangeKey + onRangeChange: (range: StatsRangeKey) => void + onRefresh: () => void + charts: ResourceChartConfig[] +}) { + return ( +
+
+

统计信息

+
+
+ {(Object.keys(rangeLabels) as StatsRangeKey[]).map((item) => ( + + ))} +
+ +
+
+ +
+ {charts.map((chart, index) => ( + + ))} +
+
+ ) +} + +function DetailedChart({ chart, className }: { chart: ResourceChartConfig; className: string }) { + const values = chart.points.map((point) => point.value) + const avg = values.length > 0 ? values.reduce((sum, value) => sum + value, 0) / values.length : 0 + const peak = values.length > 0 ? Math.max(...values) : 0 + + return ( +
+
+
+
+ {chart.icon} + {chart.title} +
+ {chart.detail &&

{chart.detail}

} +
+
+ + + +
+
+ +
+ ) +} + +function Stat({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ) +} + +function LineAreaChart({ + points, + max, + formatValue, + unitLabel, +}: { + points: ChartPoint[] + max?: number + formatValue: (value: number) => string + unitLabel?: string +}) { + const width = 520 + const height = 150 + const left = 50 + const right = 10 + const top = 8 + const bottom = 28 + const innerWidth = width - left - right + const innerHeight = height - top - bottom + const values = points.length > 0 ? points : [{ ts: Date.now(), value: 0 }] + const maxValue = Math.max(max || 0, ...values.map((point) => point.value), 1) + const minTs = values[0]?.ts || Date.now() + const maxTs = values[values.length - 1]?.ts || minTs + 1 + const span = Math.max(maxTs - minTs, 1) + + const coords = values.map((point, index) => { + const x = left + ((point.ts - minTs) / span) * innerWidth + const y = top + innerHeight - (point.value / maxValue) * innerHeight + return `${Number.isFinite(x) ? x : left},${Number.isFinite(y) ? y : top + innerHeight}` + }) + const fallbackX = left + const fallbackY = top + innerHeight + const line = coords.length > 1 ? coords.join(' ') : `${fallbackX},${fallbackY} ${left + innerWidth},${fallbackY}` + const area = `${left},${top + innerHeight} ${line} ${left + innerWidth},${top + innerHeight}` + const yTicks = [1, 0.5, 0] + const xTicks = [0, 0.5, 1] + + return ( + + + + + + + + + {yTicks.map((tick) => { + const y = top + (1 - tick) * innerHeight + return ( + + + + {formatValue(maxValue * tick)} + + + ) + })} + + {xTicks.map((tick) => { + const x = left + tick * innerWidth + const ts = minTs + tick * span + return ( + + + + {formatTime(ts)} + + + ) + })} + + {unitLabel && ( + + {unitLabel} + + )} + + + + + + + ) +} + +function chartBorderClass(index: number) { + const right = index % 2 === 0 ? 'xl:border-r' : '' + const top = index > 1 ? 'border-t' : '' + return `${right} ${top} border-gray-200` +} + +function formatTime(ts: number) { + return new Date(ts).toLocaleString('zh-CN', { + month: 'numeric', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }) +} diff --git a/frontend/src/components/RingStats.tsx b/frontend/src/components/RingStats.tsx new file mode 100644 index 0000000..9792006 --- /dev/null +++ b/frontend/src/components/RingStats.tsx @@ -0,0 +1,132 @@ +import type { ReactNode } from 'react' + +interface RingStatProps { + value: number + max?: number + label: string + subLabel?: ReactNode + size?: number + strokeWidth?: number +} + +export function RingStat({ value, max = 100, label, subLabel, size = 120, strokeWidth = 8 }: RingStatProps) { + const radius = (size - strokeWidth) / 2 + const circumference = radius * 2 * Math.PI + const percentage = Math.min(Math.max(value / max * 100, 0), 100) + const strokeDashoffset = circumference - (percentage / 100) * circumference + + return ( +
+
+ + {/* Background ring */} + + {/* Progress ring */} + + + {/* Center value */} +
+ {value.toFixed(percentage < 1 ? 2 : 1)}% +
+
+
+
{label}
+ {subLabel &&
{subLabel}
} +
+
+ ) +} + +interface RingStatsProps { + cpuPercent: number + cpuCores: number + cpuUsed: number + ramPercent: number + ramUsed: number + ramTotal: number + swapPercent?: number + swapUsed?: number + swapTotal?: number + loadPercent: number + loadStatus: string + diskPercent: number + diskUsed: number + diskTotal: number +} + +export default function RingStats({ + cpuPercent, + cpuCores, + cpuUsed, + ramPercent, + ramUsed, + ramTotal, + swapPercent = 0, + swapUsed = 0, + swapTotal = 0, + loadPercent, + loadStatus, + diskPercent, + diskUsed, + diskTotal, +}: RingStatsProps) { + const formatGB = (mb: number) => { + if (mb >= 1024) return `${(mb / 1024).toFixed(2)} GB` + return `${mb} MB` + } + + const hasSwap = swapTotal > 0 + + return ( +
+

状态

+
+ + + {hasSwap && ( + + )} + + +
+
+ ) +} diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx new file mode 100644 index 0000000..809b4c5 --- /dev/null +++ b/frontend/src/components/Sidebar.tsx @@ -0,0 +1,189 @@ +import { useLocation, useNavigate } from 'react-router-dom' +import { + ChevronLeft, + ChevronRight, + Code2, + LayoutDashboard, + LogOut, + Package, + ScrollText, + Server, + Settings2, + ShieldAlert, + UserCog, +} from 'lucide-react' +import { useAuth } from '../contexts/AuthContext' +import AppIcon from './AppIcon' + +interface SidebarProps { + collapsed: boolean + onToggle: () => void +} + +export default function Sidebar({ collapsed, onToggle }: SidebarProps) { + const navigate = useNavigate() + const location = useLocation() + const { logout, isSubUser } = useAuth() + + const isContainerPage = + location.pathname.startsWith('/containers') || + location.pathname.startsWith('/container') + + const isImagesPage = location.pathname.startsWith('/images') + const isOversellPage = location.pathname.startsWith('/oversell') + const isAuditLogsPage = location.pathname.startsWith('/audit-logs') + const isApiIntegrationPage = location.pathname.startsWith('/api-integration') + const isSecurityPage = location.pathname.startsWith('/security') + const isSettingsPage = location.pathname.startsWith('/settings') + + return ( + + ) +} diff --git a/frontend/src/components/WebSSHViewer.tsx b/frontend/src/components/WebSSHViewer.tsx new file mode 100644 index 0000000..be189cd --- /dev/null +++ b/frontend/src/components/WebSSHViewer.tsx @@ -0,0 +1,220 @@ +import { useEffect, useRef, useState } from 'react' +import { Terminal } from '@xterm/xterm' +import { FitAddon } from '@xterm/addon-fit' +import '@xterm/xterm/css/xterm.css' +import { RefreshCw, TerminalSquare, X } from 'lucide-react' +import { createWebSSHTicket } from '../services/api' + +interface WebSSHViewerProps { + containerName: string + onClose: () => void +} + +export default function WebSSHViewer({ containerName, onClose }: WebSSHViewerProps) { + const terminalRef = useRef(null) + const wsRef = useRef(null) + const termRef = useRef(null) + const fitRef = useRef(null) + const resizeObserverRef = useRef(null) + const [status, setStatus] = useState<'connecting' | 'preparing' | 'connected' | 'disconnected' | 'error'>('connecting') + const [errorMsg, setErrorMsg] = useState('') + + const buildWebSSHUrl = (ticket: string) => { + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' + const params = new URLSearchParams({ + container: containerName, + ticket, + }) + return `${protocol}//${window.location.host}/api/ssh?${params.toString()}` + } + + const sendResize = () => { + const ws = wsRef.current + const term = termRef.current + if (!ws || !term || ws.readyState !== WebSocket.OPEN) return + ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows })) + } + + const cleanup = () => { + resizeObserverRef.current?.disconnect() + resizeObserverRef.current = null + + if (wsRef.current) { + wsRef.current.close() + wsRef.current = null + } + + if (termRef.current) { + termRef.current.dispose() + termRef.current = null + fitRef.current = null + } + } + + const connect = async () => { + if (!terminalRef.current) return + + cleanup() + setStatus('connecting') + setErrorMsg('') + + const term = new Terminal({ + cursorBlink: true, + convertEol: true, + fontFamily: 'Consolas, Menlo, Monaco, monospace', + fontSize: 13, + theme: { + background: '#050505', + foreground: '#f3f4f6', + cursor: '#ffffff', + selectionBackground: '#374151', + }, + }) + const fitAddon = new FitAddon() + term.loadAddon(fitAddon) + term.open(terminalRef.current) + + termRef.current = term + fitRef.current = fitAddon + + const fitTerminal = () => { + try { + fitAddon.fit() + sendResize() + } catch { + // The modal may report zero size during the first paint. Retry below. + } + } + requestAnimationFrame(() => { + fitTerminal() + window.setTimeout(fitTerminal, 80) + window.setTimeout(fitTerminal, 250) + }) + + let ticket = '' + try { + const response = await createWebSSHTicket(containerName) + ticket = response.data.data?.ticket || '' + } catch { + setStatus('error') + setErrorMsg('WebSSH ticket 创建失败,请重新登录后再试') + return + } + if (!ticket) { + setStatus('error') + setErrorMsg('WebSSH ticket 为空,请重新登录后再试') + return + } + + const ws = new WebSocket(buildWebSSHUrl(ticket)) + ws.binaryType = 'arraybuffer' + wsRef.current = ws + + term.writeln(`Connecting to ${containerName} as root...`) + + ws.onopen = () => { + setStatus('preparing') + term.writeln('\r\nWebSocket connected. Preparing SSH shell...') + sendResize() + term.focus() + } + + ws.onmessage = async (event) => { + setStatus('connected') + if (event.data instanceof ArrayBuffer) { + term.write(new Uint8Array(event.data)) + return + } + + if (event.data instanceof Blob) { + const buffer = await event.data.arrayBuffer() + term.write(new Uint8Array(buffer)) + return + } + + term.write(String(event.data)) + } + + ws.onerror = () => { + setStatus('error') + setErrorMsg('WebSSH 连接失败,请确认容器已运行且 SSH 服务可用') + } + + ws.onclose = () => { + if (status !== 'error') { + setStatus((current) => current === 'connected' ? 'disconnected' : current) + } + } + + term.onData((data) => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(new TextEncoder().encode(data)) + } + }) + + const observer = new ResizeObserver(() => { + fitTerminal() + }) + observer.observe(terminalRef.current) + resizeObserverRef.current = observer + } + + useEffect(() => { + const timer = window.setTimeout(connect, 100) + return () => { + window.clearTimeout(timer) + cleanup() + } + }, [containerName]) + + return ( +
+
+
+ + WebSSH - {containerName} + {status === 'connected' && ( + 已连接 + )} + {status === 'connecting' && ( + 连接中... + )} + {status === 'preparing' && ( + SSH preparing... + )} + {status === 'disconnected' && ( + 已断开 + )} + {status === 'error' && ( + 连接失败 + )} +
+
+ + +
+
+ +
+
+ {status === 'error' && ( +
+ {errorMsg} +
+ )} +
+
+ ) +} diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx new file mode 100644 index 0000000..c2dff61 --- /dev/null +++ b/frontend/src/contexts/AuthContext.tsx @@ -0,0 +1,151 @@ +import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react' +import { useNavigate } from 'react-router-dom' +import api, { login as apiLogin, checkAuth, LoginResponse } from '../services/api' + +interface AuthContextType { + isAuthenticated: boolean + isLoading: boolean + username: string | null + isSubUser: boolean + containerIdentifiers: string[] + login: (username: string, password: string) => Promise + accessCodeLogin: (code: string, password: string) => Promise + logout: () => void + token: string | null +} + +const AuthContext = createContext(undefined) + +export function AuthProvider({ children }: { children: ReactNode }) { + const [isAuthenticated, setIsAuthenticated] = useState(false) + const [isLoading, setIsLoading] = useState(true) + const [username, setUsername] = useState(null) + const [isSubUser, setIsSubUser] = useState(false) + const [containerIdentifiers, setContainerIdentifiers] = useState([]) + const [token, setToken] = useState(null) + const navigate = useNavigate() + + const saveAuth = (t: string, u: string, sub: boolean, ids: string[]) => { + localStorage.setItem('clicd_token', t) + localStorage.setItem('clicd_username', u) + setToken(t) + setUsername(u) + setIsSubUser(sub) + setContainerIdentifiers(ids) + setIsAuthenticated(true) + } + + useEffect(() => { + const savedToken = localStorage.getItem('clicd_token') + const savedUsername = localStorage.getItem('clicd_username') + if (savedToken) { + const payload = decodeTokenPayload(savedToken) + const nextUsername = payload?.username || payload?.sub_user || savedUsername || null + const nextContainerIdentifiers = Array.isArray(payload?.container_uuids) && payload.container_uuids.length > 0 + ? payload.container_uuids + : Array.isArray(payload?.container_names) ? payload.container_names : [] + + setToken(savedToken) + setUsername(nextUsername) + setIsSubUser(!!payload?.sub_user) + setContainerIdentifiers(nextContainerIdentifiers) + checkAuth() + .then(() => { + setIsAuthenticated(true) + }) + .catch(() => { + localStorage.removeItem('clicd_token') + localStorage.removeItem('clicd_username') + setToken(null) + setUsername(null) + setIsSubUser(false) + setContainerIdentifiers([]) + }) + .finally(() => setIsLoading(false)) + } else { + setIsLoading(false) + } + }, [navigate]) + + const login = async (user: string, password: string) => { + try { + const response = await apiLogin(user, password) + const data = response.data.data as LoginResponse + saveAuth(data.token, data.username, false, []) + navigate('/') + } catch (adminError) { + try { + const res = await api.post('/sub-user/login', { username: user, password }) + const data = res.data.data as { token: string; username: string; container_uuids: string[] } + saveAuth(data.token, data.username, true, data.container_uuids || []) + const first = data.container_uuids?.[0] + navigate(first ? `/container/${encodeURIComponent(first)}` : '/containers') + } catch { + throw adminError + } + } + } + + const accessCodeLogin = async (code: string, password: string) => { + const res = await api.post('/sub-user/access', { code, password }) + const data = res.data.data as { token: string; username: string; container_uuids: string[] } + saveAuth(data.token, data.username, true, data.container_uuids || []) + const first = data.container_uuids?.[0] + navigate(first ? `/container/${encodeURIComponent(first)}` : '/containers') + } + + const logout = () => { + localStorage.removeItem('clicd_token') + localStorage.removeItem('clicd_username') + setToken(null) + setUsername(null) + setIsSubUser(false) + setContainerIdentifiers([]) + setIsAuthenticated(false) + navigate('/login') + } + + return ( + + {children} + + ) +} + +export function useAuth() { + const context = useContext(AuthContext) + if (context === undefined) { + throw new Error('useAuth must be used within an AuthProvider') + } + return context +} + +type TokenPayload = { + username?: string + sub_user?: string + container_names?: string[] + container_uuids?: string[] +} + +function decodeTokenPayload(token: string): TokenPayload | null { + try { + const payload = token.split('.')[1] + if (!payload) return null + const normalized = payload.replace(/-/g, '+').replace(/_/g, '/') + const padded = normalized.padEnd(normalized.length + ((4 - (normalized.length % 4)) % 4), '=') + const json = decodeURIComponent( + atob(padded) + .split('') + .map((char) => `%${(`00${char.charCodeAt(0).toString(16)}`).slice(-2)}`) + .join('') + ) + return JSON.parse(json) as TokenPayload + } catch { + return null + } +} + +function subUserTargetPath(containerIdentifiers: string[]) { + const firstContainer = containerIdentifiers[0] + return firstContainer ? `/container/${encodeURIComponent(firstContainer)}` : '/containers' +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..d6ac0a7 --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,32 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + background-color: #ffffff; + color: #000000; +} + +::-webkit-scrollbar { + width: 6px; +} + +::-webkit-scrollbar-track { + background: #f1f1f1; +} + +::-webkit-scrollbar-thumb { + background: #888; + border-radius: 3px; +} + +::-webkit-scrollbar-thumb:hover { + background: #555; +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..10013ee --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,19 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import { BrowserRouter } from 'react-router-dom' +import App from './App' +import { AuthProvider } from './contexts/AuthContext' +import { DialogProvider } from './components/Dialog' +import './index.css' + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + + + + + + + , +) diff --git a/frontend/src/pages/ApiIntegration.tsx b/frontend/src/pages/ApiIntegration.tsx new file mode 100644 index 0000000..5cb94ee --- /dev/null +++ b/frontend/src/pages/ApiIntegration.tsx @@ -0,0 +1,306 @@ +import { useState, useEffect, useCallback } from 'react' +import { Key, Plus, Trash2, Copy, RefreshCw, Code, X } from 'lucide-react' +import api, { APIResponse } from '../services/api' + +interface ApiKeyItem { + id: string + name: string + key?: string + prefix: string + ip_whitelist: string + created_at: string + last_used: string +} + +const BASE_URL = window.location.origin + +export default function ApiIntegration() { + const [keys, setKeys] = useState([]) + const [loading, setLoading] = useState(true) + const [showCreate, setShowCreate] = useState(false) + const [newName, setNewName] = useState('') + const [newIPs, setNewIPs] = useState('') + const [creating, setCreating] = useState(false) + const [newKey, setNewKey] = useState('') + const [showDocs, setShowDocs] = useState(true) + const [copiedKey, setCopiedKey] = useState(false) + + const fetchKeys = useCallback(async () => { + try { + const res = await api.get>('/api-keys') + setKeys(res.data.data || []) + } catch { /* ignore */ } + finally { setLoading(false) } + }, []) + + useEffect(() => { fetchKeys() }, [fetchKeys]) + + const createKey = async () => { + if (!newName.trim()) return + setCreating(true) + try { + const res = await api.post>('/api-keys', { + name: newName.trim(), + ip_whitelist: newIPs.trim(), + }) + if (res.data.data?.key) { + setNewKey(res.data.data.key) + setKeys(prev => [res.data.data!, ...prev]) + } + setNewName('') + setNewIPs('') + setShowCreate(false) + } catch { /* ignore */ } + finally { setCreating(false) } + } + + const deleteKey = async (id: string) => { + if (!window.confirm('确定要删除此 API Key 吗?')) return + try { + await api.delete(`/api-keys/${id}`) + setKeys(prev => prev.filter(k => k.id !== id)) + } catch { /* ignore */ } + } + + const copyKey = () => { + try { + navigator.clipboard.writeText(newKey) + } catch { + const ta = document.createElement('textarea') + ta.value = newKey + ta.style.position = 'fixed' + ta.style.left = '-9999px' + document.body.appendChild(ta) + ta.select() + document.execCommand('copy') + document.body.removeChild(ta) + } + setCopiedKey(true) + setTimeout(() => setCopiedKey(false), 2000) + } + + return ( +
+
+

API 集成

+

管理 API Key 与查看接口文档

+
+ + {/* API Keys */} +
+
+

+ API Keys +

+
+ + +
+
+ + {newKey && ( +
+
+ 新 API Key 已生成 + +
+

此 Key 仅显示一次,请立即复制保存。

+
+ {newKey} + +
+
+ )} + + {loading ? ( +
加载中...
+ ) : keys.length === 0 ? ( +
暂无 API Key,点击"创建 Key"开始
+ ) : ( +
+ + + + + + + + + + + + + {keys.map(k => ( + + + + + + + + + ))} + +
名称Key 前缀IP 白名单创建时间最后使用操作
{k.name}{k.prefix}{k.ip_whitelist || '不限制'}{k.created_at}{k.last_used || '未使用'} + +
+
+ )} +
+ + {/* Create Key Modal */} + {showCreate && ( +
+
setShowCreate(false)} /> +
+
+

创建 API Key

+ +
+
+
+ + setNewName(e.target.value)} + placeholder="例如:自动化脚本、CI/CD" + className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" + onKeyDown={e => e.key === 'Enter' && createKey()} + autoFocus + /> +
+
+ +