diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index babbc84..2293ca6 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -6,7 +6,7 @@ on: - main - master tags: - - "v*" + - 'v*' pull_request: workflow_dispatch: @@ -25,31 +25,39 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: "20" + node-version: 20 cache: npm - cache-dependency-path: frontend/package-lock.json + cache-dependency-path: | + frontend/package-lock.json + docs/package-lock.json - name: Setup Go uses: actions/setup-go@v5 with: - go-version: "1.22.x" + go-version: '1.24.5' cache-dependency-path: backend/go.sum + - name: Install frontend dependencies + run: npm ci --prefix frontend + + - name: Install docs dependencies + run: npm ci --prefix docs + + - name: Build docs + run: npm run build --prefix docs + - name: Set version from tag - shell: bash run: | if [[ "$GITHUB_REF" == refs/tags/v* ]]; then - echo "CLICD_VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV + echo "CLICD_VERSION=${GITHUB_REF#refs/tags/v}" >> "$GITHUB_ENV" else - echo "CLICD_VERSION=dev" >> $GITHUB_ENV + echo "CLICD_VERSION=dev" >> "$GITHUB_ENV" fi - - name: Build - shell: bash + - name: Build CLICD run: bash build.sh - - name: Package - shell: bash + - name: Package CLICD run: | mkdir -p dist package/clicd-linux-amd64 cp build/clicd package/clicd-linux-amd64/clicd @@ -57,7 +65,24 @@ jobs: chmod +x package/clicd-linux-amd64/clicd package/clicd-linux-amd64/install.sh 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: Package Mofang module + run: | + if [ ! -f Mofang/clicd.php ]; then + echo "Mofang module not present; skipping package." + exit 0 + fi + + if ! command -v zip >/dev/null 2>&1; then + sudo apt-get update + sudo apt-get install -y zip + fi + + cd Mofang + zip -r ../dist/clicd-mofang.zip clicd.php handlers templates -x '*.DS_Store' -x '*/.DS_Store' + + - name: Generate checksums + run: sha256sum dist/* > dist/SHA256SUMS - name: Upload artifact uses: actions/upload-artifact@v4 @@ -69,7 +94,6 @@ jobs: 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 index 5c42cca..5d40489 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ backend/internal/server/web/* # Build artifacts /build/ +Mofang/*.zip *.exe *.dll *.so diff --git a/README.md b/README.md index f3817c2..9a87f91 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@

CLICD

- Go + Go React TypeScript Vite @@ -27,41 +27,11 @@ TLS

-CLICD is a lightweight virtualization management panel for LXC and KVM, featuring a web console, CLI management, batch operations, image management, NAT networking, IPv6 allocation, WebSSH, VNC access, resource controls, bandwidth limiting, and security alerting. -It is designed for managing LXC containers and KVM virtual machines on VPS servers, and is particularly suitable for environments that require bulk provisioning and delegated access management through sub-user management links. +CLICD is a lightweight virtualization management panel for LXC and KVM. It combines a web console, CLI tools, REST API, NAT/IPv6 networking, WebSSH/WebVNC access, resource quotas, traffic limits, snapshots, delegated sub-user access, and security alerts into a single deployable service. -CLICD 是一个面向 LXC/KVM 的轻量虚拟化管理面板,提供 Web 控制台、CLI、批量任务、镜像管理、NAT 端口、IPv6 分配、WebSSH、VNC、资源限制、流量限制和安全告警能力。它适合用来管理小型 VPS 上的 LXC 容器和 KVM 虚拟机,也适合需要批量创建和分发子用户管理链接的场景。 +CLICD 是一个面向 LXC/KVM 的轻量虚拟化管理面板,集成 Web 控制台、CLI、REST API、NAT/IPv6 网络、WebSSH/WebVNC、资源配额、流量限制、快照、子用户授权和安全告警能力,适合 VPS 商家、实验室、开发者自建虚拟化节点以及需要批量开通容器的场景。 -## Features / 功能介绍 - -### English -1. Supports Ubuntu, Debian, Alpine, CentOS, Arch Linux, Fedora, Rocky Linux, and other operating system images. Images can be downloaded on demand through the image management interface. For hosts with limited resources, lightweight distributions such as Alpine are recommended. -2. Supports WebSSH management, allowing users to access container terminals directly from the browser without manually copying SSH credentials. -3. Supports NAT4 port quotas, port forwarding, and protocol restrictions, as well as public IPv6 allocation. IPv6 assignment requires the host machine to have a routable IPv6 prefix. -4. Supports both inbound and outbound traffic limits. Containers are automatically powered off when configured limits are reached, preventing bandwidth overuse. -5. Supports container expiration dates. Expired containers are automatically shut down, and delegated users lose access until an administrator extends the expiration period. -6. Includes lightweight conntrack-based security monitoring. The system does not store full logs of normal connections, but generates audit alerts for suspicious activities such as port scanning, lateral scanning, brute-force attempts, SMTP abuse, UDP reflection attacks, cryptocurrency mining ports, and proxy/VPN/Tor usage. -7. Supports delegated management links. Administrators can assign specific containers to sub-users, while ensuring that each user can only manage the containers explicitly authorized to them. -8. Provides a REST API for automating the management of containers, tasks, images, networking, traffic controls, and security alerts. -9. Supports operating entirely through the CLI. When the web console is not required, administrators can stop and disable the systemd service and launch CLI-only mode using `clicd cli --no-web`. - -### 中文 -1. 支持 Ubuntu、Debian、Alpine、CentOS、Arch Linux、Fedora、Rocky Linux 等系统镜像。镜像可以在镜像管理中按需下载;如果宿主机资源比较小,建议优先选择 Alpine 这类轻量镜像。 -2. 支持 WebSSH 管理,可以在浏览器里一键进入容器终端,不需要手动复制 SSH 密码。 -3. 支持设置 NAT4 端口数量、NAT 端口映射和协议限制,并支持分配公网 IPv6。IPv6 分配要求宿主机本身拥有可路由的 IPv6 地址段。 -4. 支持单向和双向网络流量限制。达到限制后容器会自动关机,避免流量超额。 -5. 支持设置容器有效期。到期后容器会自动关机,子用户无法继续操作,只有管理员重新设置延期日期后才能恢复使用。 -6. 内置基于 conntrack 的轻量安全告警。系统不会保存完整正常连接日志,但会对端口扫描、横向扫描、爆破倾向、SMTP 滥用、UDP 反射、挖矿端口、代理/VPN/Tor 等可疑行为生成告警并写入审计日志。 -7. 支持子用户管理链接,管理员可以把指定容器分发给拼车用户,子用户只能管理自己被授权的容器。 -8. 支持 API 接入,可以通过 API 完成容器、任务、镜像、端口、流量、安全告警等功能的自动化控制。 -9. 支持仅使用 CLI 管理。需要关闭 Web 控制台时,可以停止并禁用 systemd 服务,然后使用 `clicd cli --no-web` 进入命令行模式。 - -## Technology Stack / 技术栈 - -- Backend: Go, net/http, LXC, KVM/libvirt, cgroup v2, iptables, conntrack -- Frontend: React, TypeScript, Vite, Tailwind CSS, lucide-react, xterm.js -- Runtime: Linux, systemd, LXC, KVM/QEMU -- Build: GitHub Actions, Node.js 20, Go 1.22 +![alt text](/img/image-1.png) ## Installation / 安装 @@ -77,9 +47,52 @@ One-click Uninstall / 一键卸载: curl -fsSL https://raw.githubusercontent.com/MengMengCode/CLICD/main/install.sh | sudo sh -s -- uninstall ``` -![alt text](/img/image.png) -![alt text](/img/image-1.png) + + +## Features / 功能介绍 + +### English + +| Area | What CLICD provides | +| --- | --- | +| Virtualization | Manage LXC containers and KVM virtual machines from one panel, including create, reinstall, start, stop, restart, delete, password reset, expiry control, and batch actions. | +| Images and templates | Built-in template and image management for Ubuntu, Debian, Alpine, CentOS, Arch Linux, Fedora, Rocky Linux, and other common distributions. Images can be enabled, disabled, downloaded, cancelled, or removed from cache. | +| Networking | NAT4 port quotas, random available port allocation, TCP/UDP port mappings, public IPv4 pool management, IPv6 prefix detection, IPv6 status checks, and per-container IPv6 assignment. | +| Resource control | CPU, memory, disk, swap, bandwidth usage, traffic reset, traffic limit, and resource limit management, with automatic shutdown behavior for expired or over-quota containers. | +| Console access | Browser-based WebSSH and WebVNC ticket access, so users can open terminals or consoles without manually exchanging credentials. | +| Snapshots | Snapshot overview, per-container snapshots, create/delete/restore operations, scheduled snapshots, and quota controls. | +| Security | Conntrack-based security alerts for port scans, lateral scans, brute-force behavior, SMTP abuse, UDP reflection, mining ports, proxy/VPN/Tor usage, plus security logs, summaries, and configurable settings. | +| Accounts and audit | Delegated sub-user links, sub-user password rotation, per-user container permissions, audit logs, login logs, and API key management. | +| Automation | Versioned REST API under `/api/v1`, task queue endpoints, batch create/action endpoints, and a Mofang finance integration module packaged automatically by GitHub Actions. | +| Operations | Dashboard statistics, host resource overview, routing overview, swap management, CLI-only mode, and release artifacts generated by GitHub Actions. | + +### 中文 + +| 模块 | CLICD 提供的能力 | +| --- | --- | +| 虚拟化管理 | 在同一个面板里管理 LXC 容器和 KVM 虚拟机,支持创建、重装、开机、关机、重启、删除、重置密码、到期时间和批量操作。 | +| 镜像与模板 | 内置模板和镜像管理,支持 Ubuntu、Debian、Alpine、CentOS、Arch Linux、Fedora、Rocky Linux 等常见发行版,镜像可按需下载、取消、启用、禁用和清理缓存。 | +| 网络能力 | 支持 NAT4 端口配额、随机可用端口、TCP/UDP 端口映射、公网 IPv4 池管理、IPv6 前缀检测、IPv6 状态检查和容器级 IPv6 分配。 | +| 资源限制 | 支持 CPU、内存、磁盘、Swap、带宽用量、流量重置、流量限制和资源限制管理;容器到期或超额后可自动关机,避免资源和流量失控。 | +| 远程控制 | 内置 WebSSH 和 WebVNC 票据访问,用户可以直接在浏览器打开终端或控制台,不需要手动复制连接信息。 | +| 快照能力 | 支持快照总览、容器快照、创建快照、删除快照、恢复快照、计划快照和快照配额。 | +| 安全告警 | 基于 conntrack 做轻量安全检测,可识别端口扫描、横向扫描、爆破倾向、SMTP 滥用、UDP 反射、挖矿端口、代理/VPN/Tor 等风险,并提供安全日志、汇总和设置项。 | +| 账号与审计 | 支持子用户管理链接、子用户密码轮换、按容器授权、操作日志、登录日志和 API Key 管理,适合分发给下游用户或拼车用户。 | +| 自动化接入 | 全量接口统一使用 `/api/v1`,覆盖任务队列、容器、镜像、网络、流量、安全、批量创建和批量操作;同时提供魔方财务对接模块,并由 GitHub Actions 自动打包发布。 | +| 运维入口 | 提供总览统计、主机资源、路由概览、Swap 管理、CLI-only 模式和 GitHub Actions 自动发布产物,便于在小型节点上长期维护。 | + +## Technology Stack / 技术栈 + +- Backend: Go, net/http, LXC, KVM/libvirt, cgroup v2, iptables, conntrack +- Frontend: React, TypeScript, Vite, Tailwind CSS, lucide-react, xterm.js +- Runtime: Linux, systemd, LXC, KVM/QEMU +- Build: GitHub Actions, Node.js 20, Go 1.24 + +## Preview / 预览 ![alt text](/img/image-2.png) +![alt text](/img/image-3.png) +![alt text](/img/image-4.png) +![alt text](/img/image-5.png) ## Disclaimer/免责声明 @@ -99,10 +112,11 @@ This open-source software is intended solely for educational purposes, specifica 本开源软件仅供学习和研究 LXC、KVM 等虚拟化技术原理之目的使用,不得用于任何违反适用法律法规、软件许可协议或第三方权益的行为。 本软件中涉及的 Windows 名称、标识、图标及相关知识产权均归 Microsoft Corporation 及其权利人所有。本项目与微软公司不存在任何关联、授权或合作关系。 -## Thanks/鸣谢 - +## Thanks / 鸣谢 +- [Nodeseek.com](https://www.nodeseek.com) — 一个专注于服务器的社区 - [Linux.do](https://linux.do) — 一个充满灵感的科技社区 + ## Star History diff --git a/backend/internal/api/auth.go b/backend/internal/api/auth.go index 10157f4..f2e7b09 100644 --- a/backend/internal/api/auth.go +++ b/backend/internal/api/auth.go @@ -90,7 +90,7 @@ func hasScope(r *http.Request, scope string) bool { func subUserScopeAllowed(scope string) bool { switch scope { - case "container:read", "container:power", "container:reinstall", "container:network", + case "container:read", "container:power", "container:reinstall", "container:password", "container:network", "dashboard:read", "image:read", "task:read", "snapshot:read", "snapshot:create", "snapshot:delete", "snapshot:restore", "snapshot:schedule", "terminal:ssh", "terminal:vnc": return true diff --git a/backend/internal/api/firewall.go b/backend/internal/api/firewall.go new file mode 100644 index 0000000..35af498 --- /dev/null +++ b/backend/internal/api/firewall.go @@ -0,0 +1,154 @@ +package api + +import ( + "encoding/json" + "math/rand" + "net/http" + "strconv" + "strings" + + "clicd/internal/config" + "clicd/internal/lxc" +) + +func generateFirewallRuleID() string { + const chars = "abcdefghijklmnopqrstuvwxyz0123456789" + b := make([]byte, 8) + for i := range b { + b[i] = chars[rand.Intn(len(chars))] + } + return string(b) +} + +func getFirewall(w http.ResponseWriter, r *http.Request, id int) { + c := config.FindContainer(id) + if c == nil { + jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"}) + return + } + jsonResponse(w, http.StatusOK, APIResponse{ + Success: true, + Data: map[string]interface{}{ + "enabled": c.FirewallEnabled, + "rules": c.FirewallRules, + }, + }) +} + +func updateFirewall(w http.ResponseWriter, r *http.Request, id int) { + c := config.FindContainer(id) + if c == nil { + jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"}) + return + } + + var req struct { + Enabled *bool `json:"enabled"` + Rules *[]config.FirewallRule `json:"rules"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"}) + return + } + + if req.Enabled != nil { + c.FirewallEnabled = *req.Enabled + } + if req.Rules != nil { + // Validate and assign IDs to new rules + rules := *req.Rules + for i := range rules { + rules[i].Direction = strings.ToLower(strings.TrimSpace(rules[i].Direction)) + rules[i].Protocol = strings.ToLower(strings.TrimSpace(rules[i].Protocol)) + rules[i].Action = strings.ToUpper(strings.TrimSpace(rules[i].Action)) + rules[i].SourceIP = strings.TrimSpace(rules[i].SourceIP) + rules[i].Port = strings.TrimSpace(rules[i].Port) + + if rules[i].Direction != "in" && rules[i].Direction != "out" { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid direction: " + rules[i].Direction}) + return + } + if rules[i].Protocol != "tcp" && rules[i].Protocol != "udp" && rules[i].Protocol != "icmp" && rules[i].Protocol != "all" { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid protocol: " + rules[i].Protocol}) + return + } + if rules[i].Action != "ACCEPT" && rules[i].Action != "DROP" { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid action: " + rules[i].Action}) + return + } + if rules[i].ID == "" { + rules[i].ID = generateFirewallRuleID() + } + // Validate port spec + if rules[i].Port != "" { + if err := validatePortSpec(rules[i].Port); err != nil { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid port: " + err.Error()}) + return + } + } + } + c.FirewallRules = rules + } + + config.SaveConfig() + + // Apply firewall rules to iptables if container is running + if c.Status == "running" { + if err := lxc.ApplyFirewallRules(id); err != nil { + jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to apply firewall rules: " + err.Error()}) + return + } + } else if !c.FirewallEnabled { + // If disabled and not running, clean any lingering rules + lxc.CleanFirewallRules(id) + } + + jsonResponse(w, http.StatusOK, APIResponse{ + Success: true, + Message: "Firewall updated", + Data: map[string]interface{}{ + "enabled": c.FirewallEnabled, + "rules": c.FirewallRules, + }, + }) +} + +func validatePortSpec(port string) error { + port = strings.TrimSpace(port) + if port == "" { + return nil + } + // Support: "22", "80,443", "8000-9000", "80,443,8000-9000" + for _, part := range strings.Split(port, ",") { + part = strings.TrimSpace(part) + if part == "" { + continue + } + if strings.Contains(part, "-") { + // Range + bounds := strings.SplitN(part, "-", 2) + lo, err := strconv.Atoi(strings.TrimSpace(bounds[0])) + if err != nil || lo < 1 || lo > 65535 { + return &portValidationError{part} + } + hi, err := strconv.Atoi(strings.TrimSpace(bounds[1])) + if err != nil || hi < 1 || hi > 65535 { + return &portValidationError{part} + } + } else { + p, err := strconv.Atoi(part) + if err != nil || p < 1 || p > 65535 { + return &portValidationError{part} + } + } + } + return nil +} + +type portValidationError struct { + port string +} + +func (e *portValidationError) Error() string { + return "invalid port value: " + e.port +} diff --git a/backend/internal/api/handlers.go b/backend/internal/api/handlers.go index 0453f3d..c64808a 100644 --- a/backend/internal/api/handlers.go +++ b/backend/internal/api/handlers.go @@ -182,6 +182,16 @@ func HandleSingleContainer(w http.ResponseWriter, r *http.Request) { return } deletePortMapping(w, r, id, strings.TrimPrefix(action, "port-mappings/")) + case action == "firewall" && r.Method == http.MethodGet: + if !requireScope(w, r, "container:network") { + return + } + getFirewall(w, r, id) + case action == "firewall" && r.Method == http.MethodPut: + if !requireScope(w, r, "container:network") { + return + } + updateFirewall(w, r, id) case r.Method == http.MethodGet: if !requireScope(w, r, "container:read") { return diff --git a/backend/internal/api/security.go b/backend/internal/api/security.go index f8937c9..f53a9cd 100644 --- a/backend/internal/api/security.go +++ b/backend/internal/api/security.go @@ -180,6 +180,12 @@ func (ss *SecurityScanner) monitorLoop() { } } +func (ss *SecurityScanner) alertCount() int { + ss.mu.Lock() + defer ss.mu.Unlock() + return len(ss.alerts) +} + func (ss *SecurityScanner) checkAllContainers() { for _, c := range config.AppConfig.Containers { if c.Status != "running" || c.IP == "" { @@ -208,6 +214,7 @@ func (ss *SecurityScanner) checkContainer(name, ip string) { return } + alertBefore := ss.alertCount() ss.detectPortScans(name, ip, stats) ss.detectBruteForce(name, ip, stats) ss.detectSpam(name, ip, stats) @@ -216,6 +223,11 @@ func (ss *SecurityScanner) checkContainer(name, ip string) { ss.detectMining(name, ip, stats) ss.detectProxyAndTor(name, ip, stats) ss.detectMalware(name, ip, stats) + + // If new alerts were generated, snapshot the conntrack data for later retrieval. + if ss.alertCount() > alertBefore { + config.SaveConntrackSnapshot(ip, lines) + } } func newTrafficStats() *trafficStats { @@ -759,28 +771,49 @@ func HandleSecurityLogs(w http.ResponseWriter, r *http.Request) { func getConnectionLogs(ip string) []map[string]interface{} { logs := make([]map[string]interface{}, 0) + seen := map[string]bool{} - for _, line := range readConntrackLines(ip) { + parseLine := func(line string) map[string]interface{} { srcIP := extractField(line, "src=") dstIP := extractField(line, "dst=") srcPort := extractField(line, "sport=") dstPort := extractField(line, "dport=") - sPort, _ := strconv.Atoi(srcPort) dPort, _ := strconv.Atoi(dstPort) - - logs = append(logs, map[string]interface{}{ + return map[string]interface{}{ "src_ip": srcIP, "dst_ip": dstIP, "src_port": sPort, "dst_port": dPort, "protocol": extractProtocol(line), "state": extractConnState(line), - }) + } + } + // First, load stored snapshots from database (persisted at alert time). + for _, line := range config.GetConntrackSnapshotLines(ip) { if len(logs) >= 100 { break } + key := strings.TrimSpace(line) + if key == "" || seen[key] { + continue + } + seen[key] = true + logs = append(logs, parseLine(line)) + } + + // Then, merge live conntrack data (deduplicated). + for _, line := range readConntrackLines(ip) { + if len(logs) >= 100 { + break + } + key := strings.TrimSpace(line) + if key == "" || seen[key] { + continue + } + seen[key] = true + logs = append(logs, parseLine(line)) } return logs diff --git a/backend/internal/api/subuser.go b/backend/internal/api/subuser.go index 70ddcbe..7713e16 100644 --- a/backend/internal/api/subuser.go +++ b/backend/internal/api/subuser.go @@ -373,6 +373,15 @@ func SubUserMiddleware(next http.HandlerFunc) http.HandlerFunc { return } + imagesEnabledPath := "/api/images/enabled" + if strings.HasPrefix(path, "/api/v1/") { + imagesEnabledPath = "/api/v1/images/enabled" + } + if path == imagesEnabledPath && r.Method == http.MethodGet { + next(w, r) + return + } + if path == containerListPath { if r.Method != http.MethodGet { jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Sub-users cannot create containers"}) @@ -503,7 +512,7 @@ func isSubUserContainerActionAllowed(action string, method string) bool { return method == http.MethodPost case strings.HasPrefix(action, "snapshots/"): return method == http.MethodDelete || method == http.MethodPost - case action == "start" || action == "stop" || action == "restart" || action == "reinstall": + case action == "start" || action == "stop" || action == "restart" || action == "reinstall" || action == "reset-password": return method == http.MethodPost case strings.HasPrefix(action, "port-mappings/"): return method == http.MethodPut diff --git a/backend/internal/cli/cli.go b/backend/internal/cli/cli.go index ca4588d..3d26060 100644 --- a/backend/internal/cli/cli.go +++ b/backend/internal/cli/cli.go @@ -34,7 +34,7 @@ var cliTranslations = map[string]string{ "请选择操作": "Select an action", "再见": "Goodbye", "无效选择": "Invalid choice", - "CLICD - LXC 容器管理器": "CLICD - LXC Container Manager", + "CLICD - LXC 容器管理器": "CLICD - Container Manager", "Web 面板": "Web panel", "端口": "port", "运行中": "running", diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index cd54f0e..3f46883 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -22,6 +22,17 @@ type PortMapping struct { Description string `json:"description"` } +type FirewallRule struct { + ID string `json:"id"` + Direction string `json:"direction"` // "in" or "out" + Protocol string `json:"protocol"` // "tcp", "udp", "icmp", "all" + Port string `json:"port"` // "" = all, "22", "80,443", "8000-9000" + SourceIP string `json:"source_ip"` // "" = any + Action string `json:"action"` // "ACCEPT" or "DROP" + Description string `json:"description"` + Enabled bool `json:"enabled"` +} + type PublicIPv4Assignment struct { Address string `json:"address"` Interface string `json:"interface,omitempty"` @@ -124,6 +135,8 @@ type Container struct { SSHHostKey string `json:"ssh_host_key,omitempty"` PortMappings []PortMapping `json:"port_mappings"` PortMappingLimit int `json:"port_mapping_limit"` + FirewallEnabled bool `json:"firewall_enabled"` + FirewallRules []FirewallRule `json:"firewall_rules"` SnapshotLimit int `json:"snapshot_limit"` CreatedAt string `json:"created_at"` ExpiresAt string `json:"expires_at"` diff --git a/backend/internal/config/store_sqlite.go b/backend/internal/config/store_sqlite.go index 9cb9dcc..8231817 100644 --- a/backend/internal/config/store_sqlite.go +++ b/backend/internal/config/store_sqlite.go @@ -254,6 +254,14 @@ func ensureSchema() error { success INTEGER, error TEXT )`, + `CREATE TABLE IF NOT EXISTS security_conntrack_snapshots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + container_ip TEXT NOT NULL, + line TEXT NOT NULL, + captured_at TEXT NOT NULL + )`, + `CREATE INDEX IF NOT EXISTS idx_conntrack_snapshots_ip_time + ON security_conntrack_snapshots(container_ip, captured_at)`, `CREATE TABLE IF NOT EXISTS tasks ( id TEXT PRIMARY KEY, type TEXT, @@ -356,6 +364,8 @@ func ensureSchemaMigrations() error { {"port_mappings", "host_ip", "TEXT"}, {"container_public_ipv4s", "prefix_len", "INTEGER"}, {"container_public_ipv4s", "gateway", "TEXT"}, + {"containers", "firewall_enabled", "INTEGER NOT NULL DEFAULT 0"}, + {"containers", "firewall_rules", "TEXT"}, } { if err := ensureColumn(column.table, column.name, column.def); err != nil { return err @@ -575,8 +585,9 @@ func saveContainers(tx *sql.Tx) error { ssh_host_key, port_mapping_limit, snapshot_limit, created_at, expires_at, snapshot_schedule_enabled, snapshot_schedule_interval_hours, snapshot_schedule_time, snapshot_schedule_last_run, snapshot_schedule_next_run, snapshot_schedule_created_by, - policy_blocked, policy_blocked_reason, policy_blocked_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + policy_blocked, policy_blocked_reason, policy_blocked_at, + firewall_enabled, firewall_rules + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, c.ID, c.UUID, c.Name, c.Virtualization, c.LXCName, c.KVMName, c.DiskImage, c.MACAddress, c.Template, c.VCPU, c.RAMMB, c.DiskGB, c.NetworkBWMbps, c.MonthlyTrafficGB, c.TrafficMode, c.TrafficInGB, c.TrafficOutGB, c.TrafficUsedRX, c.TrafficUsedTX, c.TrafficResetDate, c.IOSpeedMBps, @@ -585,6 +596,7 @@ func saveContainers(tx *sql.Tx) error { boolInt(c.SnapshotScheduleEnabled), c.SnapshotScheduleIntervalHours, c.SnapshotScheduleTime, c.SnapshotScheduleLastRun, c.SnapshotScheduleNextRun, c.SnapshotScheduleCreatedBy, boolInt(c.PolicyBlocked), c.PolicyBlockedReason, c.PolicyBlockedAt, + boolInt(c.FirewallEnabled), marshalFirewallRules(c.FirewallRules), ); err != nil { return err } @@ -642,6 +654,59 @@ func saveAPIKeys(tx *sql.Tx) error { return nil } +// SaveConntrackSnapshot stores raw conntrack lines for a container IP. +func SaveConntrackSnapshot(containerIP string, lines []string) { + if db == nil || len(lines) == 0 || strings.TrimSpace(containerIP) == "" { + return + } + now := time.Now().Format("2006-01-02 15:04:05") + tx, err := db.Begin() + if err != nil { + return + } + defer tx.Rollback() + stmt, err := tx.Prepare(`INSERT INTO security_conntrack_snapshots (container_ip, line, captured_at) VALUES (?, ?, ?)`) + if err != nil { + return + } + defer stmt.Close() + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + stmt.Exec(containerIP, line, now) + } + tx.Commit() + + // Cleanup old snapshots (>1 hour) + db.Exec(`DELETE FROM security_conntrack_snapshots WHERE captured_at < ?`, + time.Now().Add(-1*time.Hour).Format("2006-01-02 15:04:05")) +} + +// GetConntrackSnapshotLines returns stored conntrack lines for a container IP. +func GetConntrackSnapshotLines(containerIP string) []string { + if db == nil || strings.TrimSpace(containerIP) == "" { + return nil + } + rows, err := db.Query( + `SELECT line FROM security_conntrack_snapshots WHERE container_ip = ? ORDER BY captured_at DESC LIMIT 200`, + containerIP, + ) + if err != nil { + return nil + } + defer rows.Close() + var lines []string + for rows.Next() { + var line string + if rows.Scan(&line) == nil { + lines = append(lines, line) + } + } + return lines +} + func saveAuditLogs(tx *sql.Tx) error { for _, log := range AppConfig.AuditLogs { successSet := 0 @@ -728,7 +793,8 @@ func loadContainers() ([]Container, error) { ssh_host_key, port_mapping_limit, snapshot_limit, created_at, expires_at, snapshot_schedule_enabled, snapshot_schedule_interval_hours, snapshot_schedule_time, snapshot_schedule_last_run, snapshot_schedule_next_run, snapshot_schedule_created_by, - policy_blocked, policy_blocked_reason, policy_blocked_at + policy_blocked, policy_blocked_reason, policy_blocked_at, + firewall_enabled, firewall_rules FROM containers ORDER BY id`) if err != nil { return nil, err @@ -738,7 +804,8 @@ func loadContainers() ([]Container, error) { result := []Container{} for rows.Next() { var c Container - var scheduleEnabled, policyBlocked int + var scheduleEnabled, policyBlocked, firewallEnabled int + var firewallRulesJSON sql.NullString if err := rows.Scan( &c.ID, &c.UUID, &c.Name, &c.Virtualization, &c.LXCName, &c.KVMName, &c.DiskImage, &c.MACAddress, &c.Template, &c.VCPU, &c.RAMMB, &c.DiskGB, &c.NetworkBWMbps, &c.MonthlyTrafficGB, &c.TrafficMode, &c.TrafficInGB, @@ -748,11 +815,16 @@ func loadContainers() ([]Container, error) { &scheduleEnabled, &c.SnapshotScheduleIntervalHours, &c.SnapshotScheduleTime, &c.SnapshotScheduleLastRun, &c.SnapshotScheduleNextRun, &c.SnapshotScheduleCreatedBy, &policyBlocked, &c.PolicyBlockedReason, &c.PolicyBlockedAt, + &firewallEnabled, &firewallRulesJSON, ); err != nil { return nil, err } c.SnapshotScheduleEnabled = scheduleEnabled != 0 c.PolicyBlocked = policyBlocked != 0 + c.FirewallEnabled = firewallEnabled != 0 + if firewallRulesJSON.Valid && strings.TrimSpace(firewallRulesJSON.String) != "" { + _ = json.Unmarshal([]byte(firewallRulesJSON.String), &c.FirewallRules) + } result = append(result, c) } if err := rows.Err(); err != nil { @@ -1106,6 +1178,17 @@ func boolInt(value bool) int { return 0 } +func marshalFirewallRules(rules []FirewallRule) interface{} { + if len(rules) == 0 { + return nil + } + data, err := json.Marshal(rules) + if err != nil { + return nil + } + return string(data) +} + func boolPtrInt(value *bool) interface{} { if value == nil { return nil diff --git a/backend/internal/kvm/kvm.go b/backend/internal/kvm/kvm.go index 5387940..2158c8d 100644 --- a/backend/internal/kvm/kvm.go +++ b/backend/internal/kvm/kvm.go @@ -84,6 +84,7 @@ var ( lastTrafficSnapshot = map[string]trafficSample{} kvmSnapshotMu sync.Mutex kvmSSHEnsureLocks sync.Map + knownSSHHostKeys sync.Map // TOFU host key store: host:port → ssh.PublicKey portMapApplyMu sync.Mutex lastPortMapApply = map[int]time.Time{} windowsMetricsMu sync.Mutex @@ -407,6 +408,7 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig mac := randomMAC() sshPassword := generateRandomString(16) sshPublicKey := "" + sshAuthMode := "" if !IsWindowsImage(image.ID) { sshAccess, err := lxc.ResolveCreateSSHAccess(cfg) if err != nil { @@ -414,6 +416,7 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig } sshPassword = sshAccess.Password sshPublicKey = sshAccess.PublicKey + sshAuthMode = sshAccess.Mode } publicIPv4s, err := lxc.AllocatePublicIPv4Assignments(id, cfg.PublicIPv4s, cfg.IPv4Count, cfg.AssignIPv4) if err != nil { @@ -429,7 +432,9 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig ipv6Assignments = assigned } ipv6List := configIPv6AssignmentAddresses(ipv6Assignments) - defaultHostIP := lxc.DefaultPortMappingHostIP(publicIPv4s) + ipv4List := configIPv4AssignmentAddresses(publicIPv4s) + // NAT4 port mappings should bind to the host IP, not the VM's independent public IPv4. + defaultHostIP := "" var xml string winAdminPassword := "" @@ -448,7 +453,7 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig } winAdminPassword = generateWindowsPassword() unattendPath := filepath.Join(m.instanceDir(vmName), "unattend.iso") - if err := createWindowsUnattendISO(unattendPath, cfg.Name, winAdminPassword, ipv6List); err != nil { + if err := createWindowsUnattendISO(unattendPath, cfg.Name, winAdminPassword, ipv6List, ipv4List); err != nil { return nil, err } xml = windowsDomainXML(vmName, int(cfg.VCPU), cfg.RAMMB, diskPath, ImagePath(image.ID), unattendPath, mac, cfg.IOSpeedMBps, cfg.NetworkBWMbps) @@ -464,7 +469,7 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig if err := createOverlayDisk(ImagePath(image.ID), diskPath, cfg.DiskGB); err != nil { return nil, err } - if err := createSeedISO(seedPath, vmName, cfg.Name, sshPassword, sshPublicKey, mac, ipv6List, *image); err != nil { + if err := createSeedISO(seedPath, vmName, cfg.Name, sshPassword, sshPublicKey, mac, ipv6List, ipv4List, *image, sshAuthMode); err != nil { return nil, err } xml = domainXML(vmName, int(cfg.VCPU), cfg.RAMMB, diskPath, seedPath, mac, cfg.IOSpeedMBps, cfg.NetworkBWMbps, image.Desktop != "") @@ -585,7 +590,7 @@ func (m *Manager) StartContainer(id int) error { } } } - config.UpdateContainerStatus(id, "running") + config.UpdateContainerStatus(id, "initializing") // Detect VNC port if _, err := m.RefreshVNCPort(id); err != nil { fmt.Printf("Warning: failed to refresh VNC port for %s: %v\n", name, err) @@ -621,7 +626,15 @@ func (m *Manager) StartContainer(id int) error { if err := lxc.NewManager().ApplyPortMappings(id); err != nil { return err } + if err := lxc.ApplyFirewallRules(id); err != nil { + fmt.Printf("Warning: failed to apply firewall rules: %v\n", err) + } } + // Wait for cloud-init to finish and SSH to be reachable (password-only mode) + if !isWindows && c.IP != "" { + m.waitForCloudInitReady(name, c.IP, c.SSHPassword) + } + config.UpdateContainerStatus(id, "running") if c.IPv6 != "" || len(c.IPv6Addresses) > 0 { if err := m.applyIPv6Runtime(c); err != nil { return err @@ -632,12 +645,71 @@ func (m *Manager) StartContainer(id int) error { return nil } +// waitForCloudInitReady waits for cloud-init to finish and SSH to be reachable. +// tofuHostKeyCallback implements Trust-On-First-Use host key verification. +// On the first connection to a host, the key is accepted and remembered. +// Subsequent connections must present the same key or the connection is rejected. +func tofuHostKeyCallback(hostname string, remote net.Addr, key ssh.PublicKey) error { + if stored, ok := knownSSHHostKeys.Load(hostname); ok { + if bytes.Equal(stored.(ssh.PublicKey).Marshal(), key.Marshal()) { + return nil + } + return fmt.Errorf("host key mismatch for %s (possible MitM attack)", hostname) + } + knownSSHHostKeys.Store(hostname, key) + return nil +} + +func (m *Manager) waitForCloudInitReady(vmName, ip, password string) { + if ip == "" || password == "" { + return + } + deadline := time.Now().Add(3 * time.Minute) + target := net.JoinHostPort(ip, "22") + sshWasUp := false + qgaAttempted := false + + for time.Now().Before(deadline) { + client, err := ssh.Dial("tcp", target, &ssh.ClientConfig{ + User: "root", + Auth: []ssh.AuthMethod{ssh.Password(password)}, + HostKeyCallback: tofuHostKeyCallback, + Timeout: 5 * time.Second, + }) + if err == nil { + client.Close() + if !sshWasUp { + sshWasUp = true + fmt.Printf("KVM %s SSH up, waiting for cloud-init to settle...\n", vmName) + time.Sleep(10 * time.Second) + continue + } + fmt.Printf("KVM %s ready\n", vmName) + return + } + + // Try guest agent ONCE to speed things up, with timeout to avoid blocking + if !qgaAttempted && qemuGuestPing(vmName) == nil { + qgaAttempted = true + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + cmd := exec.CommandContext(ctx, "virsh", "qemu-agent-command", vmName, + `{"execute":"guest-exec","arguments":{"path":"/bin/sh","arg":["-c","cloud-init status --wait 2>/dev/null; systemctl restart sshd 2>/dev/null || systemctl restart ssh 2>/dev/null; true"],"capture-output":false}}`) + cmd.Run() + cancel() + } + + time.Sleep(5 * time.Second) + } + fmt.Printf("Warning: KVM %s not ready after 3 minutes\n", vmName) +} + func (m *Manager) StopContainer(id int) error { c := config.FindContainer(id) if c == nil { return fmt.Errorf("container not found: %d", id) } _ = lxc.NewManager().CleanPortMappings(id) + lxc.CleanFirewallRules(id) name := c.VirshName() status, _ := m.GetContainerStatus(name) if status != "running" { @@ -1115,6 +1187,7 @@ func (m *Manager) prepareVMForColdCopy(id int, name string) (bool, error) { time.Sleep(time.Second) } else { _ = lxc.NewManager().CleanPortMappings(id) + lxc.CleanFirewallRules(id) } return wasRunning, nil } @@ -1389,7 +1462,7 @@ func (m *Manager) RefreshNetwork(id int) (string, error) { if changed { config.SaveConfig() } - if c.Status == "running" && len(c.PortMappings) > 0 && shouldApplyPortMappings(id, changed) { + if c.Status == "running" && shouldApplyPortMappings(id, changed) { if err := lxc.NewManager().ApplyPortMappings(id); err != nil { return ip, err } @@ -1596,7 +1669,7 @@ func createEmptyDisk(target string, diskGB int) error { return nil } -func createWindowsUnattendISO(target, hostname, adminPassword string, ipv6s []string) error { +func createWindowsUnattendISO(target, hostname, adminPassword string, ipv6s []string, ipv4s []string) error { tool := firstAvailableCommand("genisoimage", "mkisofs", "xorriso") if tool == "" { return fmt.Errorf("one of genisoimage, mkisofs, xorriso is required for Windows unattended setup") @@ -1622,13 +1695,13 @@ func createWindowsUnattendISO(target, hostname, adminPassword string, ipv6s []st if err := os.WriteFile(filepath.Join(setupScriptsDir, "SetupComplete.cmd"), []byte(windowsSetupCompleteCMD()), 0600); err != nil { return err } - if err := os.WriteFile(filepath.Join(clicdDir, "FirstLogon.ps1"), []byte(windowsFirstLogonPowerShell(adminPassword, ipv6s)), 0600); err != nil { + if err := os.WriteFile(filepath.Join(clicdDir, "FirstLogon.ps1"), []byte(windowsFirstLogonPowerShell(adminPassword, ipv6s, ipv4s)), 0600); err != nil { return err } if err := os.WriteFile(filepath.Join(dir, "SetupComplete.cmd"), []byte(windowsSetupCompleteCMD()), 0600); err != nil { return err } - if err := os.WriteFile(filepath.Join(dir, "FirstLogon.ps1"), []byte(windowsFirstLogonPowerShell(adminPassword, ipv6s)), 0600); err != nil { + if err := os.WriteFile(filepath.Join(dir, "FirstLogon.ps1"), []byte(windowsFirstLogonPowerShell(adminPassword, ipv6s, ipv4s)), 0600); err != nil { return err } _ = os.Remove(target) @@ -1738,7 +1811,7 @@ exit /b 0 ` } -func windowsFirstLogonPowerShell(adminPassword string, ipv6s []string) string { +func windowsFirstLogonPowerShell(adminPassword string, ipv6s []string, ipv4s []string) string { commands := []string{ "$ErrorActionPreference='Continue'", "$ProgressPreference='SilentlyContinue'", @@ -1774,6 +1847,12 @@ func windowsFirstLogonPowerShell(adminPassword string, ipv6s []string) string { windowsIPv6PowerShell(ipv6s), ) } + ipv4s = normalizeKVMIPv4List(ipv4s) + if len(ipv4s) > 0 { + commands = append(commands, + windowsIPv4PowerShell(ipv4s), + ) + } commands = append(commands, "New-Item -ItemType File -Force -Path 'C:\\CLICD\\init.done' | Out-Null", "} finally { Stop-Transcript | Out-Null }", @@ -1792,8 +1871,7 @@ func windowsIPv6PowerShell(ipv6s []string) string { } return strings.Join([]string{ "$clicdIPv6=@(" + strings.Join(quoted, ",") + ")", - "$iface=$null", - "for ($i=0; $i -lt 60 -and -not $iface; $i++) { $iface=Get-NetAdapter | Where-Object { $_.Status -eq 'Up' -and $_.HardwareInterface } | Sort-Object ifIndex | Select-Object -First 1; if (-not $iface) { Start-Sleep -Seconds 5 } }", + // Reuse $iface already found by the main script "if ($iface) {", " foreach ($ip in $clicdIPv6) {", " Get-NetIPAddress -InterfaceIndex $iface.ifIndex -AddressFamily IPv6 -ErrorAction SilentlyContinue | Where-Object { $_.IPAddress -eq $ip } | Remove-NetIPAddress -Confirm:$false -ErrorAction SilentlyContinue", @@ -1806,12 +1884,48 @@ func windowsIPv6PowerShell(ipv6s []string) string { }, "\r\n") } +func windowsIPv4PowerShell(ipv4s []string) string { + ipv4s = normalizeKVMIPv4List(ipv4s) + if len(ipv4s) == 0 { + return "" + } + quoted := make([]string, 0, len(ipv4s)) + for _, ipv4 := range ipv4s { + quoted = append(quoted, "'"+strings.ReplaceAll(ipv4, "'", "''")+"'") + } + return strings.Join([]string{ + "$clicdIPv4=@(" + strings.Join(quoted, ",") + ")", + // Reuse $iface already found by the main script + "if ($iface) {", + " foreach ($ip in $clicdIPv4) {", + " Get-NetIPAddress -InterfaceIndex $iface.ifIndex -AddressFamily IPv4 -ErrorAction SilentlyContinue | Where-Object { $_.IPAddress -eq $ip } | Remove-NetIPAddress -Confirm:$false -ErrorAction SilentlyContinue", + " New-NetIPAddress -IPAddress $ip -PrefixLength 32 -InterfaceIndex $iface.ifIndex -SkipAsSource:$false -ErrorAction SilentlyContinue | Out-Null", + " }", + "}", + }, "\r\n") +} + +func normalizeKVMIPv4List(values []string) []string { + seen := map[string]bool{} + result := make([]string, 0, len(values)) + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" || seen[value] { + continue + } + seen[value] = true + result = append(result, value) + } + return result +} + func shellQuoteWindows(value string) string { return `"` + strings.ReplaceAll(value, `"`, `\"`) + `"` } -func createSeedISO(seedPath, instanceID, hostname, password, publicKey, mac string, ipv6s []string, image Image) error { - guestSetup := kvmSSHSetupScript(password, publicKey) +func createSeedISO(seedPath, instanceID, hostname, password, publicKey, mac string, ipv6s []string, ipv4s []string, image Image, sshAuthMode string) error { + disablePubkey := sshAuthMode == "password" || sshAuthMode == "auto_password" + guestSetup := kvmSSHSetupScript(password, disablePubkey, publicKey) if desktopSetup := kvmDesktopSetupScript(image); desktopSetup != "" { guestSetup += "\n" + desktopSetup } @@ -1846,29 +1960,40 @@ runcmd: %s `, hostname, password, authorizedKeys, setupScript) metaData := fmt.Sprintf("instance-id: %s\nlocal-hostname: %s\n", instanceID, hostname) - ipv6Block := "" + + // Build static address block (IPv4 + IPv6) + ipv4s = normalizeKVMIPv4List(ipv4s) + addressBlock := "" + addressLines := make([]string, 0, len(ipv4s)+len(ipv6s)) + for _, ipv4 := range ipv4s { + addressLines = append(addressLines, fmt.Sprintf(" - %s/32", ipv4)) + } + for _, ipv6 := range ipv6s { + addressLines = append(addressLines, fmt.Sprintf(" - %s/128", ipv6)) + } + if len(addressLines) > 0 { + addressBlock = fmt.Sprintf("\n addresses:\n%s", strings.Join(addressLines, "\n")) + } + + // IPv6 routes (only needed when IPv6 addresses are configured) + ipv6RouteBlock := "" if len(ipv6s) > 0 { - addressLines := make([]string, 0, len(ipv6s)) - for _, ipv6 := range ipv6s { - addressLines = append(addressLines, fmt.Sprintf(" - %s/128", ipv6)) - } - ipv6Block = fmt.Sprintf(` - addresses: -%s + ipv6RouteBlock = fmt.Sprintf(` routes: - to: default via: %s on-link: true - metric: 100`, strings.Join(addressLines, "\n"), ipv6GatewayLinkLocal) + metric: 100`, ipv6GatewayLinkLocal) } + networkConfig := fmt.Sprintf(`version: 2 ethernets: nic0: match: macaddress: "%s" dhcp4: true - dhcp6: false%s -`, strings.ToLower(mac), ipv6Block) + dhcp6: false%s%s +`, strings.ToLower(mac), addressBlock, ipv6RouteBlock) dir := filepath.Dir(seedPath) userPath := filepath.Join(dir, "user-data") metaPath := filepath.Join(dir, "meta-data") @@ -1900,6 +2025,16 @@ func configIPv6AssignmentAddresses(assignments []config.IPv6Assignment) []string return values } +func configIPv4AssignmentAddresses(assignments []config.PublicIPv4Assignment) []string { + values := make([]string, 0, len(assignments)) + for _, item := range assignments { + if strings.TrimSpace(item.Address) != "" { + values = append(values, strings.TrimSpace(item.Address)) + } + } + return values +} + func normalizeKVMIPv6List(values []string) []string { seen := map[string]bool{} result := make([]string, 0, len(values)) @@ -2356,6 +2491,9 @@ func (m *Manager) EnsureSSH(id int) error { if mapErr := lxc.NewManager().ApplyPortMappings(id); mapErr != nil { return mapErr } + if err := lxc.ApplyFirewallRules(id); err != nil { + fmt.Printf("Warning: failed to apply firewall rules: %v\n", err) + } return nil } if lastErr == nil { @@ -2368,11 +2506,11 @@ func runKVMGuestAgentSSHSetup(name string, password string) error { if err := qemuGuestPing(name); err != nil { return err } - return qemuGuestExec(name, kvmSSHSetupScript(password), 180*time.Second) + return qemuGuestExec(name, kvmSSHSetupScript(password, false), 180*time.Second) } func runKVMSSHSetup(client *ssh.Client, password string) error { - return runKVMSSHScript(client, kvmSSHSetupScript(password), "KVM SSH", 150*time.Second) + return runKVMSSHScript(client, kvmSSHSetupScript(password, false), "KVM SSH", 150*time.Second) } func runKVMSSHScript(client *ssh.Client, script string, description string, timeout time.Duration) error { @@ -2401,12 +2539,16 @@ func runKVMSSHScript(client *ssh.Client, script string, description string, time } } -func kvmSSHSetupScript(password string, publicKeys ...string) string { +func kvmSSHSetupScript(password string, disablePubkeyAuth bool, publicKeys ...string) string { publicKey := "" if len(publicKeys) > 0 { publicKey = strings.TrimSpace(publicKeys[0]) } - return `set -u + pubkeyValue := "yes" + if disablePubkeyAuth { + pubkeyValue = "no" + } + script := `set -u ROOT_PASSWORD=` + shellQuote(password) + ` SSH_PUBLIC_KEY=` + shellQuote(publicKey) + ` export DEBIAN_FRONTEND=noninteractive @@ -2435,7 +2577,7 @@ fi mkdir -p /etc/ssh/sshd_config.d cat > /etc/ssh/sshd_config.d/99-clicd-root.conf <<'EOF' PermitRootLogin yes -PubkeyAuthentication yes +PubkeyAuthentication __CLICD_PUBKEY_AUTH__ PasswordAuthentication yes KbdInteractiveAuthentication yes ChallengeResponseAuthentication yes @@ -2443,8 +2585,8 @@ EOF if [ -f /etc/ssh/sshd_config ]; then grep -q '^PermitRootLogin ' /etc/ssh/sshd_config && sed -i 's/^PermitRootLogin .*/PermitRootLogin yes/' /etc/ssh/sshd_config || printf '\nPermitRootLogin yes\n' >> /etc/ssh/sshd_config grep -q '^#PermitRootLogin ' /etc/ssh/sshd_config && sed -i 's/^#PermitRootLogin .*/PermitRootLogin yes/' /etc/ssh/sshd_config || true - grep -q '^PubkeyAuthentication ' /etc/ssh/sshd_config && sed -i 's/^PubkeyAuthentication .*/PubkeyAuthentication yes/' /etc/ssh/sshd_config || printf '\nPubkeyAuthentication yes\n' >> /etc/ssh/sshd_config - grep -q '^#PubkeyAuthentication ' /etc/ssh/sshd_config && sed -i 's/^#PubkeyAuthentication .*/PubkeyAuthentication yes/' /etc/ssh/sshd_config || true + grep -q '^PubkeyAuthentication ' /etc/ssh/sshd_config && sed -i 's/^PubkeyAuthentication .*/PubkeyAuthentication __CLICD_PUBKEY_AUTH__/' /etc/ssh/sshd_config || printf '\nPubkeyAuthentication __CLICD_PUBKEY_AUTH__\n' >> /etc/ssh/sshd_config + grep -q '^#PubkeyAuthentication ' /etc/ssh/sshd_config && sed -i 's/^#PubkeyAuthentication .*/PubkeyAuthentication __CLICD_PUBKEY_AUTH__/' /etc/ssh/sshd_config || true grep -q '^PasswordAuthentication ' /etc/ssh/sshd_config && sed -i 's/^PasswordAuthentication .*/PasswordAuthentication yes/' /etc/ssh/sshd_config || printf '\nPasswordAuthentication yes\n' >> /etc/ssh/sshd_config grep -q '^#PasswordAuthentication ' /etc/ssh/sshd_config && sed -i 's/^#PasswordAuthentication .*/PasswordAuthentication yes/' /etc/ssh/sshd_config || true grep -q '^KbdInteractiveAuthentication ' /etc/ssh/sshd_config && sed -i 's/^KbdInteractiveAuthentication .*/KbdInteractiveAuthentication yes/' /etc/ssh/sshd_config || printf '\nKbdInteractiveAuthentication yes\n' >> /etc/ssh/sshd_config @@ -2459,7 +2601,10 @@ if [ -n "$SSH_PUBLIC_KEY" ]; then chown -R root:root /root/.ssh 2>/dev/null || true fi if command -v chpasswd >/dev/null 2>&1; then - printf 'root:%s\n' "$ROOT_PASSWORD" | chpasswd || true + printf 'root:%s\n' "$ROOT_PASSWORD" | chpasswd 2>/tmp/clicd-chpasswd.log && echo "root password set via chpasswd" || echo "WARNING: chpasswd failed: $(cat /tmp/clicd-chpasswd.log 2>/dev/null)" +elif command -v openssl >/dev/null 2>&1 && command -v usermod >/dev/null 2>&1; then + HASH=$(echo "$ROOT_PASSWORD" | openssl passwd -6 -stdin 2>/dev/null) + [ -n "$HASH" ] && usermod -p "$HASH" root 2>/dev/null && echo "root password set via openssl/usermod" || echo "WARNING: openssl/usermod failed" fi ssh-keygen -A >/dev/null 2>&1 || true if command -v systemctl >/dev/null 2>&1; then @@ -2485,6 +2630,8 @@ if [ -w /dev/tty1 ]; then printf '\nCLICD VNC console is ready. Press Enter for login prompt.\n' >/dev/tty1 || true fi ` + script = strings.ReplaceAll(script, "__CLICD_PUBKEY_AUTH__", pubkeyValue) + return script } func kvmDesktopSetupScript(image Image) string { diff --git a/backend/internal/lxc/lxc.go b/backend/internal/lxc/lxc.go index a9eeb98..fcfafaf 100644 --- a/backend/internal/lxc/lxc.go +++ b/backend/internal/lxc/lxc.go @@ -345,12 +345,7 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error { // Setup default port mappings (SSH only) portMappings = SetupDefaultPortMappings(sshPort) - defaultHostIP := defaultPortMappingHostIP(publicIPv4s) - if defaultHostIP != "" { - for i := range portMappings { - portMappings[i].HostIP = defaultHostIP - } - } + // NAT4 port mappings should bind to the host IP, not the container's independent public IPv4. tempC := &config.Container{ID: id, PublicIPv4s: publicIPv4s, PortMappings: portMappings} extraPorts := cfg.ExtraPorts @@ -364,7 +359,7 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error { pm, err := normalizePortMapping(tempC, -1, config.PortMapping{ ContainerPort: containerPort, HostPort: containerPort, - HostIP: defaultHostIP, + HostIP: "", Protocol: "tcp", Description: fmt.Sprintf("Port-%d", containerPort), }) @@ -424,7 +419,7 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error { fmt.Printf("Warning: failed to install IPv6 init in %s: %v\n", lxcName, err) } } - if err := m.preconfigureSSH(rootfsPath, cfg.TemplateID); err != nil { + if err := m.preconfigureSSH(rootfsPath, cfg.TemplateID, sshAccess.Mode); err != nil { fmt.Printf("Warning: failed to pre-configure SSH in %s: %v\n", lxcName, err) } if sshAccess.PublicKey != "" { @@ -512,11 +507,13 @@ IPv6AcceptRA=no } // preconfigureSSH installs and configures SSH directly in the rootfs before first boot. -func (m *Manager) preconfigureSSH(rootfsPath, templateID string) error { +func (m *Manager) preconfigureSSH(rootfsPath, templateID string, sshAuthMode string) error { _ = templateID + // Disable pubkey auth when user chose password-only mode (password or auto_password) + disablePubkey := sshAuthMode == SSHAuthPassword || sshAuthMode == SSHAuthAutoPassword ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second) defer cancel() - cmd, err := m.rootfsCommand(rootfsPath, "sh", "-c", sshSetupScript(false)) + cmd, err := m.rootfsCommand(rootfsPath, "sh", "-c", sshSetupScript(false, disablePubkey)) if err != nil { return err } @@ -1327,6 +1324,9 @@ func (m *Manager) StartContainer(id int) error { if err := m.ApplyPortMappings(id); err != nil { fmt.Printf("Warning: failed to apply port mappings: %v\n", err) } + if err := ApplyFirewallRules(id); err != nil { + fmt.Printf("Warning: failed to apply firewall rules: %v\n", err) + } if c.IPv6 != "" || len(c.IPv6Addresses) > 0 { if err := m.ApplyIPv6(id); err != nil { fmt.Printf("Warning: failed to apply IPv6 routing for %s: %v\n", lxcName, err) @@ -1475,11 +1475,13 @@ func (m *Manager) StopContainer(id int) error { if status != "running" { config.UpdateContainerStatus(id, "stopped") m.CleanPortMappings(id) + CleanFirewallRules(id) m.cleanupBandwidthLimit(lxcName) return nil } m.CleanPortMappings(id) + CleanFirewallRules(id) m.cleanupBandwidthLimit(lxcName) cmd := exec.Command("lxc-stop", "-n", lxcName) @@ -1758,7 +1760,7 @@ func (m *Manager) EnsureSSH(id int) error { config.SaveConfig() } - script := sshSetupScript(true) + script := sshSetupScript(true, false) // keep pubkey enabled for runtime ensure ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second) defer cancel() @@ -1826,7 +1828,11 @@ func (m *Manager) containerPortListening(lxcName string, port int) bool { return exec.CommandContext(ctx, "lxc-attach", "-n", lxcName, "--", "sh", "-c", check).Run() == nil } -func sshSetupScript(startService bool) string { +func sshSetupScript(startService bool, disablePubkeyAuth bool) string { + pubkeyValue := "yes" + if disablePubkeyAuth { + pubkeyValue = "no" + } script := `set -u # DNS setup: handle both traditional /etc/resolv.conf and systemd-resolved (Ubuntu 24.04). @@ -1945,7 +1951,7 @@ ssh-keygen -A >/dev/null 2>&1 || true cat >/etc/ssh/sshd_config.d/99-clicd.conf <<'EOF' PermitRootLogin yes -PubkeyAuthentication yes +PubkeyAuthentication __CLICD_PUBKEY_AUTH__ PasswordAuthentication yes KbdInteractiveAuthentication no ChallengeResponseAuthentication no @@ -1953,7 +1959,7 @@ UsePAM no EOF set_sshd_option PermitRootLogin yes -set_sshd_option PubkeyAuthentication yes +set_sshd_option PubkeyAuthentication __CLICD_PUBKEY_AUTH__ set_sshd_option PasswordAuthentication yes set_sshd_option KbdInteractiveAuthentication no set_sshd_option ChallengeResponseAuthentication no @@ -1981,6 +1987,7 @@ ensure_sshd_runtime_dir exit 32 } ` + script = strings.ReplaceAll(script, "__CLICD_PUBKEY_AUTH__", pubkeyValue) if !startService { return script } @@ -2054,7 +2061,7 @@ func (m *Manager) ResetSSHPassword(id int, password string) (string, error) { return "", err } rootfsPath := filepath.Join(m.LxcPath, lxcName, "rootfs") - if err := m.preconfigureSSH(rootfsPath, c.Template); err != nil { + if err := m.preconfigureSSH(rootfsPath, c.Template, ""); err != nil { return "", fmt.Errorf("failed to configure SSH: %v", err) } if err := m.setRootfsPassword(rootfsPath, newPassword); err != nil { @@ -2583,6 +2590,7 @@ func (m *Manager) ReinstallContainer(id int, templateID string, authConfig ...Co // Clean port mappings temporarily m.CleanPortMappings(id) + CleanFirewallRules(id) // Download the new OS into a temporary container, then replace only the // existing rootfs. The target container directory and config are preserved. @@ -2626,7 +2634,7 @@ func (m *Manager) ReinstallContainer(id int, templateID string, authConfig ...Co } } c.SSHPassword = sshAccess.Password - if err := m.preconfigureSSH(rootfsPath, templateID); err != nil { + if err := m.preconfigureSSH(rootfsPath, templateID, sshAccess.Mode); err != nil { fmt.Printf("Warning: failed to pre-configure SSH in %s after reinstall: %v\n", lxcName, err) } if sshAccess.PublicKey != "" { diff --git a/backend/internal/lxc/portmap.go b/backend/internal/lxc/portmap.go index ddc577e..d3fa0c0 100644 --- a/backend/internal/lxc/portmap.go +++ b/backend/internal/lxc/portmap.go @@ -59,11 +59,50 @@ func (m *Manager) ApplyPortMappings(id int) error { } } + // When container has public IPv4, apply full port passthrough DNAT so the + // container owns all ports on its public IP (no NAT management needed). + if len(c.PublicIPv4s) > 0 { + ensureIndependentIPv4Ingress(c, tag) + } + applyIPv4EgressPolicy(c, bridge, subnet, tag) return nil } +func ensureIndependentIPv4Ingress(c *config.Container, tag string) { + if c == nil || c.IP == "" || len(c.PublicIPv4s) == 0 { + return + } + + for _, assignment := range c.PublicIPv4s { + hostIP := strings.TrimSpace(assignment.Address) + if hostIP == "" { + continue + } + // Full port passthrough: DNAT all TCP+UDP traffic on this public IP to the container. + for _, proto := range []string{"tcp", "udp"} { + args := []string{ + "-t", "nat", + "-I", "PREROUTING", "1", + "-d", hostIP, + "-p", proto, + "-j", "DNAT", + "--to-destination", c.IP, + "-m", "comment", "--comment", fmt.Sprintf("clicd-%s-%s-all-%s", tag, natRuleIPTag(hostIP), proto), + } + cmd := exec.Command("iptables", args...) + output, err := cmd.CombinedOutput() + if err != nil { + fmt.Printf("Warning: failed to apply %s passthrough %s->%s: %v, output: %s\n", + proto, hostIP, c.IP, err, string(output)) + continue + } + fmt.Printf("IPv4 passthrough (%s): %s -> %s (all ports)\n", proto, hostIP, c.IP) + } + } +} + func applyIPv4EgressPolicy(c *config.Container, bridge, subnet, tag string) { if c == nil || strings.TrimSpace(c.IP) == "" { return @@ -529,3 +568,176 @@ func hostPortKey(hostIP string, port int) int { } return port + (sum % 1000000 * 100000) } + +// CleanFirewallRules removes all firewall rules for a container from the FORWARD chain. +func CleanFirewallRules(id int) { + tag := clicdTag(id) + // Remove all rules with the firewall tag prefix + cmd := exec.Command("bash", "-c", + fmt.Sprintf("iptables -S FORWARD 2>/dev/null | grep 'clicd-%s-fw-' | sed 's/^-A /-D /' | while read rule; do iptables $rule; done", tag)) + cmd.CombinedOutput() + + // Also remove legacy default policy rules (without specific rule ID) + for _, suffix := range []string{"default-in", "default-out"} { + for _, proto := range []string{"tcp", "udp"} { + exec.Command("iptables", "-D", "FORWARD", + "-m", "comment", "--comment", fmt.Sprintf("clicd-%s-fw-%s-%s", tag, suffix, proto), + ).CombinedOutput() + } + } +} + +// ApplyFirewallRules applies iptables FORWARD rules for a container's firewall configuration. +func ApplyFirewallRules(id int) error { + c := config.FindContainer(id) + if c == nil { + return fmt.Errorf("container not found: %d", id) + } + + // Always clean existing firewall rules first + CleanFirewallRules(id) + + // If firewall is disabled or no rules, nothing to apply + if !c.FirewallEnabled { + return nil + } + + bridge := "lxcbr0" + if c.IsKVM() { + bridge = "virbr0" + } + containerIP := c.IP + if containerIP == "" { + return nil + } + tag := clicdTag(id) + + // Apply default DROP policy first (inserted at position 1). + // Then insert ACCEPT rules (also at position 1), which pushes the DROPs down. + // Final order: ACCEPT rules on top, DROP defaults below, bridge ACCEPT rules at the bottom. + applyDefaultFirewallPolicy(tag, bridge, containerIP) + + for _, rule := range c.FirewallRules { + if !rule.Enabled { + continue + } + if err := applyOneFirewallRule(tag, bridge, containerIP, rule); err != nil { + fmt.Printf("Warning: failed to apply firewall rule %s for container %d: %v\n", rule.ID, id, err) + } + } + + return nil +} + +func applyOneFirewallRule(tag, bridge, containerIP string, rule config.FirewallRule) error { + commentTag := fmt.Sprintf("clicd-%s-fw-%s", tag, rule.ID) + + // Build base iptables args + args := []string{"-I", "FORWARD", "1"} + + // Direction: in = traffic arriving at container (-i bridge -d containerIP) + // out = traffic leaving container (-o bridge -s containerIP) + switch rule.Direction { + case "in": + args = append(args, "-i", bridge, "-d", containerIP+"/32") + case "out": + args = append(args, "-o", bridge, "-s", containerIP+"/32") + default: + return fmt.Errorf("invalid direction: %s", rule.Direction) + } + + // Protocol + switch rule.Protocol { + case "tcp", "udp": + args = append(args, "-p", rule.Protocol) + case "icmp": + args = append(args, "-p", "icmp") + case "all": + // no protocol filter + default: + return fmt.Errorf("invalid protocol: %s", rule.Protocol) + } + + // Port matching (only for tcp/udp) + if rule.Port != "" && (rule.Protocol == "tcp" || rule.Protocol == "udp") { + // For "in" direction, traffic going TO the container uses --dport + // For "out" direction, traffic going FROM the container uses --dport (destination port on remote) + args = append(args, "--dport", normalizePortSpec(rule.Port)) + } + + // Source IP filter (for "out" direction, this matches the remote source; for "in", it matches the sender) + if rule.SourceIP != "" { + switch rule.Direction { + case "in": + args = append(args, "-s", rule.SourceIP) + case "out": + args = append(args, "-d", rule.SourceIP) + } + } + + // Action + action := "DROP" + if rule.Action == "ACCEPT" { + action = "ACCEPT" + } + args = append(args, "-j", action) + + // Comment tag for cleanup + args = append(args, "-m", "comment", "--comment", commentTag) + + cmd := exec.Command("iptables", args...) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("iptables error: %s", string(output)) + } + return nil +} + +// normalizePortSpec converts user port input to iptables-compatible port spec. +// "80,443" -> "80,443", "8000-9000" -> "8000:9000", "22" -> "22" +func normalizePortSpec(port string) string { + port = strings.TrimSpace(port) + if port == "" { + return "" + } + // Convert comma-separated to iptables format (already valid) + // Convert dash range to colon range: "8000-9000" -> "8000:9000" + if strings.Contains(port, "-") && !strings.Contains(port, ":") { + parts := strings.SplitN(port, "-", 2) + if len(parts) == 2 { + return strings.TrimSpace(parts[0]) + ":" + strings.TrimSpace(parts[1]) + } + } + return port +} + +func applyDefaultFirewallPolicy(tag, bridge, containerIP string) { + // Default DROP: inserted at position 1 so they sit above bridge ACCEPT rules. + // The user-defined ACCEPT rules (also at position 1) were inserted first, + // so they end up above these DROP defaults after the position-1 insertions. + for _, proto := range []string{"tcp", "udp"} { + args := []string{ + "-I", "FORWARD", "1", + "-i", bridge, + "-d", containerIP + "/32", + "-p", proto, + "-j", "DROP", + "-m", "comment", "--comment", fmt.Sprintf("clicd-%s-fw-default-in-%s", tag, proto), + } + cmd := exec.Command("iptables", args...) + cmd.CombinedOutput() + } + + for _, proto := range []string{"tcp", "udp"} { + args := []string{ + "-I", "FORWARD", "1", + "-o", bridge, + "-s", containerIP + "/32", + "-p", proto, + "-j", "DROP", + "-m", "comment", "--comment", fmt.Sprintf("clicd-%s-fw-default-out-%s", tag, proto), + } + cmd := exec.Command("iptables", args...) + cmd.CombinedOutput() + } +} diff --git a/backend/internal/server/web/.gitkeep b/backend/internal/server/web/.gitkeep index e69de29..8d1c8b6 100644 --- a/backend/internal/server/web/.gitkeep +++ b/backend/internal/server/web/.gitkeep @@ -0,0 +1 @@ + diff --git a/backend/internal/version/version.go b/backend/internal/version/version.go index 9352eb9..c993d5c 100644 --- a/backend/internal/version/version.go +++ b/backend/internal/version/version.go @@ -1,7 +1,7 @@ package version var ( - Version = "1.1.12" + Version = "1.1.17" Repo = "MengMengCode/CLICD" ) diff --git a/frontend/index.html b/frontend/index.html index 5c1f271..2a91b89 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,7 +4,7 @@ - CLICD - LXC Container Manager + CLICD - Container Manager