From 875cd4716b4ca7e3615c7dc5dd1d2dfc9500cd1f Mon Sep 17 00:00:00 2001 From: MengMengCode <227010654+MengMengCode@users.noreply.github.com> Date: Fri, 12 Jun 2026 01:35:04 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E4=BA=86=E6=9C=8D=E5=8A=A1?= =?UTF-8?q?=E5=99=A8=E9=98=B2=E7=81=AB=E5=A2=99=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/api/auth.go | 2 +- backend/internal/api/firewall.go | 154 +++++++++++++++ backend/internal/api/handlers.go | 10 + backend/internal/api/subuser.go | 11 +- backend/internal/config/config.go | 13 ++ backend/internal/config/store_sqlite.go | 30 ++- backend/internal/kvm/kvm.go | 8 + backend/internal/lxc/lxc.go | 6 + backend/internal/lxc/portmap.go | 173 +++++++++++++++++ frontend/src/pages/ApiIntegration.tsx | 36 ++++ frontend/src/pages/ContainerDetail.tsx | 237 ++++++++++++++++++++++-- frontend/src/pages/Containers.tsx | 2 + frontend/src/services/api.ts | 19 ++ frontend/src/utils/i18n.ts | 32 ++++ 14 files changed, 711 insertions(+), 22 deletions(-) create mode 100644 backend/internal/api/firewall.go 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/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/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 459076b..8231817 100644 --- a/backend/internal/config/store_sqlite.go +++ b/backend/internal/config/store_sqlite.go @@ -364,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 @@ -583,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, @@ -593,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 } @@ -789,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 @@ -799,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, @@ -809,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 { @@ -1167,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 6901b48..2158c8d 100644 --- a/backend/internal/kvm/kvm.go +++ b/backend/internal/kvm/kvm.go @@ -626,6 +626,9 @@ 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 != "" { @@ -706,6 +709,7 @@ func (m *Manager) StopContainer(id int) error { 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" { @@ -1183,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 } @@ -2486,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 { diff --git a/backend/internal/lxc/lxc.go b/backend/internal/lxc/lxc.go index 7b2fc48..fcfafaf 100644 --- a/backend/internal/lxc/lxc.go +++ b/backend/internal/lxc/lxc.go @@ -1324,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) @@ -1472,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) @@ -2585,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. diff --git a/backend/internal/lxc/portmap.go b/backend/internal/lxc/portmap.go index beb52f0..d3fa0c0 100644 --- a/backend/internal/lxc/portmap.go +++ b/backend/internal/lxc/portmap.go @@ -568,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/frontend/src/pages/ApiIntegration.tsx b/frontend/src/pages/ApiIntegration.tsx index ee3f806..2f70120 100644 --- a/frontend/src/pages/ApiIntegration.tsx +++ b/frontend/src/pages/ApiIntegration.tsx @@ -176,6 +176,8 @@ const endpointGroups: Array<{ title: string; endpoints: EndpointTuple[] }> = [ ['POST', '/api/v1/containers/{id}/port-mappings', '添加端口映射'], ['PUT', '/api/v1/containers/{id}/port-mappings/{index}', '更新端口映射'], ['DELETE', '/api/v1/containers/{id}/port-mappings/{index}', '删除端口映射'], + ['GET', '/api/v1/containers/{id}/firewall', '获取防火墙设置'], + ['PUT', '/api/v1/containers/{id}/firewall', '更新防火墙设置'], ['GET', '/api/v1/snapshots', '快照总览'], ['GET', '/api/v1/containers/{id}/snapshots', '容器快照'], ['POST', '/api/v1/containers/{id}/snapshots', '创建快照'], @@ -817,6 +819,14 @@ const requestBodySamples: Record> = { limit: 64, }, 'POST /api/v1/security/check': { container_name: 'example-vm' }, + 'PUT /api/v1/containers/{id}/firewall': { + enabled: true, + rules: [ + { id: '', direction: 'in', protocol: 'tcp', port: '22', source_ip: '', action: 'ACCEPT', description: 'Allow SSH', enabled: true }, + { id: '', direction: 'in', protocol: 'tcp', port: '80,443', source_ip: '', action: 'ACCEPT', description: 'Allow HTTP/HTTPS', enabled: true }, + { id: '', direction: 'out', protocol: 'tcp', port: '', source_ip: '', action: 'ACCEPT', description: 'Allow all outbound TCP', enabled: true }, + ], + }, 'PUT /api/v1/security/settings': { auto_shutdown: false }, 'POST /api/v1/swap': { action: 'resize', size_mb: 16384 }, 'POST /api/v1/batch-create': { @@ -1025,6 +1035,29 @@ const responseSamples: Record = { data: [{ container_port: 8081, host_port: 61320, protocol: 'tcp', description: 'HTTP' }], }, 'DELETE /api/v1/containers/{id}/port-mappings/{index}': { success: true, data: [] }, + 'GET /api/v1/containers/{id}/firewall': { + success: true, + data: { + enabled: true, + rules: [ + { id: 'a1b2c3d4', direction: 'in', protocol: 'tcp', port: '22', source_ip: '', action: 'ACCEPT', description: 'Allow SSH', enabled: true }, + { id: 'e5f6g7h8', direction: 'in', protocol: 'tcp', port: '80,443', source_ip: '', action: 'ACCEPT', description: 'Allow HTTP/HTTPS', enabled: true }, + { id: 'i9j0k1l2', direction: 'out', protocol: 'tcp', port: '', source_ip: '', action: 'ACCEPT', description: 'Allow all outbound TCP', enabled: true }, + ], + }, + }, + 'PUT /api/v1/containers/{id}/firewall': { + success: true, + message: 'Firewall updated', + data: { + enabled: true, + rules: [ + { id: 'a1b2c3d4', direction: 'in', protocol: 'tcp', port: '22', source_ip: '', action: 'ACCEPT', description: 'Allow SSH', enabled: true }, + { id: 'e5f6g7h8', direction: 'in', protocol: 'tcp', port: '80,443', source_ip: '', action: 'ACCEPT', description: 'Allow HTTP/HTTPS', enabled: true }, + { id: 'i9j0k1l2', direction: 'out', protocol: 'tcp', port: '', source_ip: '', action: 'ACCEPT', description: 'Allow all outbound TCP', enabled: true }, + ], + }, + }, 'GET /api/v1/snapshots': { success: true, data: null }, 'GET /api/v1/containers/{id}/snapshots': { success: true, @@ -1139,6 +1172,9 @@ function endpointNoteFor(key: string) { if (key === 'POST /api/v1/batch-create') { notes.push('批量创建的单个 containers[] 项支持与 POST /api/v1/containers 相同的网络和 SSH 认证字段。') } + if (key === 'PUT /api/v1/containers/{id}/firewall') { + notes.push('启用防火墙后默认拒绝所有 TCP/UDP 入站和出站流量,仅放行 rules 中定义的规则。direction: in=入站, out=出站。action: ACCEPT=放行, DROP=拒绝。port 支持单端口(22)、多端口(80,443)、范围(8000-9000)。') + } if (key === 'POST /api/v1/batch-action') { notes.push('action=reinstall 时可追加 template_id、ssh_auth_mode、ssh_password、ssh_public_key;其他 action 会忽略这些重装字段。') } diff --git a/frontend/src/pages/ContainerDetail.tsx b/frontend/src/pages/ContainerDetail.tsx index c436954..f498592 100644 --- a/frontend/src/pages/ContainerDetail.tsx +++ b/frontend/src/pages/ContainerDetail.tsx @@ -19,7 +19,6 @@ import { Plus, RefreshCw, Save, - Settings, Square, TerminalSquare, @@ -48,6 +47,7 @@ import { TrafficInfo, getEnabledImages, PortMapping, + FirewallRule, reinstallContainer, resetSSHPassword, restartContainer, @@ -57,6 +57,7 @@ import { SnapshotSchedule, Template, updateContainerExpiry, + updateFirewall, updateSnapshotQuota, updateSnapshotSchedule, restoreContainerSnapshot, @@ -163,6 +164,12 @@ export default function ContainerDetail() { const [snapshotBusy, setSnapshotBusy] = useState('') const [showSnapshotSchedule, setShowSnapshotSchedule] = useState(false) const [snapshotScheduleDraft, setSnapshotScheduleDraft] = useState({ intervalHours: 24, time: '03:00' }) + const [showFirewall, setShowFirewall] = useState(false) + const [firewallEnabled, setFirewallEnabled] = useState(false) + const [firewallRules, setFirewallRules] = useState([]) + const [firewallSaving, setFirewallSaving] = useState(false) + const [editingFirewallRule, setEditingFirewallRule] = useState(null) + const [showFirewallEditor, setShowFirewallEditor] = useState(false) const fetchContainer = useCallback(async () => { if (!containerIdentifier) return @@ -410,6 +417,61 @@ export default function ContainerDetail() { } } + const openFirewall = () => { + if (!container) return + setFirewallEnabled(container.firewall_enabled || false) + setFirewallRules(container.firewall_rules ? [...container.firewall_rules.map(r => ({ ...r }))] : []) + setShowFirewall(true) + } + + const saveFirewall = async () => { + if (!container) return + setFirewallSaving(true) + try { + await updateFirewall(container.id, { enabled: firewallEnabled, rules: firewallRules }) + fetchContainer() + } catch (err: any) { + dialog.alert('错误', err?.response?.data?.message || '保存防火墙设置失败') + } finally { + setFirewallSaving(false) + } + } + + const addFirewallRule = () => { + setEditingFirewallRule({ + id: '', + direction: 'in', + protocol: 'tcp', + port: '', + source_ip: '', + action: 'DROP', + description: '', + enabled: true, + }) + setShowFirewallEditor(true) + } + + const saveFirewallRule = (rule: FirewallRule) => { + if (rule.id) { + // Update existing + setFirewallRules(firewallRules.map(r => r.id === rule.id ? rule : r)) + } else { + // Add new with temporary ID + const newRule = { ...rule, id: `tmp-${Date.now()}` } + setFirewallRules([...firewallRules, newRule]) + } + setShowFirewallEditor(false) + setEditingFirewallRule(null) + } + + const deleteFirewallRule = (ruleId: string) => { + setFirewallRules(firewallRules.filter(r => r.id !== ruleId)) + } + + const toggleFirewallRule = (ruleId: string) => { + setFirewallRules(firewallRules.map(r => r.id === ruleId ? { ...r, enabled: !r.enabled } : r)) + } + const openReinstall = async () => { try { const res = await getEnabledImages(container?.virtualization || 'lxc') @@ -929,24 +991,24 @@ export default function ContainerDetail() { 管理链接 )} - {!hasIndependentIPv4 && ( - <> - setShowNat(true)}> - - IPv4 NAT 管理 - - + {!hasIndependentIPv4 && hasNATQuota && ( + setShowNat(true)}> + + IPv4 NAT 管理 + )} + setShowFirewall(true)} disabled={isSubUserPolicyBlocked}> + + 防火墙 + setShowSnapshots(true)} disabled={!!taskStatus || !!snapshotBusy || isSubUserPolicyBlocked}> 快照 - {!isSubUser && ( - - - {isExpired ? '已到期' : taskStatus === 'reinstall' ? taskActionLabels['reinstall'] : '重装'} - - )} + + + {isExpired ? '已到期' : taskStatus === 'reinstall' ? taskActionLabels['reinstall'] : '重装'} + {!isSubUser && ( handleAction('delete')}> @@ -970,7 +1032,7 @@ export default function ContainerDetail() {
)} + {showFirewall && ( + { setShowFirewall(false); setShowFirewallEditor(false); setEditingFirewallRule(null) }} wide extra={ + !isSubUser && ( + + ) + }> +
+ {/* Global toggle */} +
+
+
防火墙
+
启用后默认拒绝所有入站和出站流量,仅放行下方规则
+
+ +
+ + {/* Rules table */} +
+ + + + + + + + + + + {!isSubUser && } + + + + {firewallRules.map((rule) => ( + + + + + + + + + {!isSubUser && ( + + )} + + ))} + {firewallRules.length === 0 && ( + + )} + +
状态方向协议端口来源/目标 IP动作描述操作
+ + + + {rule.direction === 'in' ? '入站' : '出站'} + + {rule.protocol.toUpperCase()}{rule.port || '全部'}{rule.source_ip || '任意'} + + {rule.action === 'ACCEPT' ? '放行' : '拒绝'} + + {rule.description || '-'} +
+ + +
+
暂无防火墙规则
+
+ + {/* Save button */} + {!isSubUser && ( +
+ +
+ )} +
+
+ )} + + {showFirewallEditor && editingFirewallRule && ( + { setShowFirewallEditor(false); setEditingFirewallRule(null) }}> +
+ + + + + + + + setEditingFirewallRule({ ...editingFirewallRule, port: e.target.value })} placeholder="如: 22 或 80,443 或 8000-9000" className={inputClass} /> + + + setEditingFirewallRule({ ...editingFirewallRule, source_ip: e.target.value })} placeholder="如: 192.168.1.0/24" className={inputClass} /> + + + + + + setEditingFirewallRule({ ...editingFirewallRule, description: e.target.value })} placeholder="规则描述" className={inputClass} /> + +
+ + +
+
+
+ )} + {showNat && !hasIndependentIPv4 && ( { setShowNat(false); setDraft(emptyDraft); setShowMappingEditor(false) }} wide extra={ !isSubUser && canAddMapping && ( @@ -1680,6 +1876,14 @@ function RangeSwitch({ value, onChange }: { value: StatsRangeKey; onChange: (val ) } +function FirewallIcon({ className }: { className?: string }) { + return ( + + + + ) +} + function StatusBadge({ running, initializing }: { running: boolean; initializing?: boolean }) { if (initializing) { return ( @@ -2041,11 +2245,12 @@ function TableHead({ children }: { children: ReactNode }) { return {children} } -function Field({ label, children }: { label: string; children: ReactNode }) { +function Field({ label, children, hint }: { label: string; children: ReactNode; hint?: string }) { return ( ) } diff --git a/frontend/src/pages/Containers.tsx b/frontend/src/pages/Containers.tsx index 55f61f5..494eea9 100644 --- a/frontend/src/pages/Containers.tsx +++ b/frontend/src/pages/Containers.tsx @@ -724,6 +724,8 @@ function toPlaceholder(cfg: CreateContainerRequest): DisplayContainer { ssh_password: '', port_mappings: [], port_mapping_limit: cfg.assign_nat === false ? 0 : (cfg.port_mapping_count || 0), + firewall_enabled: false, + firewall_rules: [], snapshot_limit: cfg.snapshot_limit || 3, created_at: '', expires_at: cfg.expires_at, diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index cc6efa2..0b01f07 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -45,6 +45,17 @@ export interface PortMapping { description: string } +export interface FirewallRule { + id: string + direction: 'in' | 'out' + protocol: 'tcp' | 'udp' | 'icmp' | 'all' + port: string + source_ip: string + action: 'ACCEPT' | 'DROP' + description: string + enabled: boolean +} + export interface PublicIPv4Assignment { address: string interface?: string @@ -88,6 +99,8 @@ export interface Container { ssh_password: string port_mappings: PortMapping[] port_mapping_limit: number + firewall_enabled: boolean + firewall_rules: FirewallRule[] snapshot_limit: number created_at: string expires_at: string @@ -487,6 +500,12 @@ export const updatePortMapping = (id: ContainerIdentifier, index: number, data: export const deletePortMapping = (id: ContainerIdentifier, index: number) => api.delete>(`/containers/${id}/port-mappings/${index}`) +export const getFirewall = (id: ContainerIdentifier) => + api.get>(`/containers/${id}/firewall`) + +export const updateFirewall = (id: ContainerIdentifier, data: { enabled?: boolean; rules?: FirewallRule[] }) => + api.put>(`/containers/${id}/firewall`, data) + export const updateContainerExpiry = (id: ContainerIdentifier, expiresAt: string) => api.put(`/containers/${id}/expiry`, { expires_at: expiresAt }) diff --git a/frontend/src/utils/i18n.ts b/frontend/src/utils/i18n.ts index 5968cf6..ac4494b 100644 --- a/frontend/src/utils/i18n.ts +++ b/frontend/src/utils/i18n.ts @@ -788,6 +788,37 @@ const exact: Record = { '50 / 页': '50 / page', '全局快照列表,共': 'Global snapshot list, total', '容器分配的子用户列表,共': 'Sub-user list assigned to containers, total', + '防火墙': 'Firewall', + '防火墙设置': 'Firewall Settings', + '独立 IPv4': 'Dedicated IPv4', + '添加规则': 'Add Rule', + '启用后默认拒绝所有入站和出站流量,仅放行下方规则': 'When enabled, all inbound and outbound traffic is blocked by default. Only the rules below are allowed.', + '方向': 'Direction', + '来源/目标 IP': 'Source / Destination IP', + '动作': 'Action', + '入站': 'Inbound', + '出站': 'Outbound', + '任意': 'Any', + '放行': 'Allow', + '拒绝': 'Deny', + '暂无防火墙规则': 'No firewall rules', + '编辑规则': 'Edit Rule', + '入站 (Inbound)': 'Inbound', + '出站 (Outbound)': 'Outbound', + '留空为全部端口,支持: 22 | 80,443 | 8000-9000': 'Leave empty for all ports. Supports: 22 | 80,443 | 8000-9000', + '如: 22 或 80,443 或 8000-9000': 'e.g. 22 or 80,443 or 8000-9000', + '来源 IP': 'Source IP', + '目标 IP': 'Destination IP', + '留空为任意 IP,支持 CIDR: 192.168.1.0/24': 'Leave empty for any IP. Supports CIDR: 192.168.1.0/24', + '如: 192.168.1.0/24': 'e.g. 192.168.1.0/24', + '放行 (ACCEPT)': 'Allow (ACCEPT)', + '拒绝 (DROP)': 'Deny (DROP)', + '规则描述': 'Rule description', + '登录方式': 'SSH Auth Method', + '保留当前密码': 'Keep current password', + '生成新密码': 'Generate new password', + '自定义密码': 'Custom password', + '生成密码': 'Generate password', } const artifactPatterns: RegExp[] = [ @@ -853,6 +884,7 @@ const replacements: Array<[RegExp, string]> = [ [/搜索\s*"([^"]+)"\s*结果\s*(\d+)\s*个地址/g, 'Search "$1" returned $2 addresses, '], [/(\d+)\s*个/g, '$1 items'], [/(\d+)\s*条/g, '$1 records'], + [/1\s*核\b/g, '1 core'], [/(\d+)\s*核/g, '$1 cores'], [/(\d+)\s*线程/g, '$1 threads'], [/已用/g, 'used'],