From baf213e769e54cc102a5e5be03c9842d00009aa2 Mon Sep 17 00:00:00 2001 From: Meng Meng Date: Fri, 12 Jun 2026 12:00:38 +0800 Subject: [PATCH 1/9] Potential fix for code scanning alert no. 28: DOM text reinterpreted as HTML Patch Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- Mofang/templates/firewall.html | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/Mofang/templates/firewall.html b/Mofang/templates/firewall.html index 4ca45e4..3450595 100644 --- a/Mofang/templates/firewall.html +++ b/Mofang/templates/firewall.html @@ -204,9 +204,12 @@ return; } rulesContainer.innerHTML = currentRules.map(function(rule, idx) { - var dir = (rule.direction || 'in').toLowerCase(); - var proto = (rule.protocol || 'tcp').toLowerCase(); - var action = (rule.action || 'ACCEPT').toUpperCase(); + var dirRaw = String(rule.direction || 'in').toLowerCase(); + var protoRaw = String(rule.protocol || 'tcp').toLowerCase(); + var actionRaw = String(rule.action || 'ACCEPT').toUpperCase(); + var dir = (dirRaw === 'in' || dirRaw === 'out') ? dirRaw : 'in'; + var proto = (protoRaw === 'tcp' || protoRaw === 'udp' || protoRaw === 'icmp' || protoRaw === 'all') ? protoRaw : 'tcp'; + var action = (actionRaw === 'ACCEPT' || actionRaw === 'DROP' || actionRaw === 'REJECT') ? actionRaw : 'ACCEPT'; var port = escapeHtml(portDisplay(rule.port)); var srcIp = escapeHtml(sourceIpDisplay(rule.source_ip)); var desc = escapeHtml(rule.description || ''); From 4de86c458f84f8d57e7f2b2b484595a6faf33fb3 Mon Sep 17 00:00:00 2001 From: MengMengCode <227010654+MengMengCode@users.noreply.github.com> Date: Fri, 12 Jun 2026 13:18:54 +0800 Subject: [PATCH 2/9] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E4=BA=86=E4=B8=80?= =?UTF-8?q?=E4=BA=9B=E5=B7=B2=E7=9F=A5=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Mofang/clicd.php | 8 + backend/internal/api/firewall.go | 123 +++++++++- backend/internal/config/config.go | 14 +- backend/internal/config/store_sqlite.go | 21 +- backend/internal/kvm/kvm.go | 7 +- backend/internal/lxc/ipv6.go | 3 + backend/internal/lxc/portmap.go | 300 ++++++++++++++++++++---- frontend/src/pages/ApiIntegration.tsx | 23 +- frontend/src/pages/ContainerDetail.tsx | 178 ++++++++++++-- frontend/src/pages/Containers.tsx | 1 + frontend/src/services/api.ts | 8 +- 11 files changed, 587 insertions(+), 99 deletions(-) diff --git a/Mofang/clicd.php b/Mofang/clicd.php index d7c313a..7c0282a 100644 --- a/Mofang/clicd.php +++ b/Mofang/clicd.php @@ -1445,6 +1445,7 @@ function clicd_firewallUpdate($params) $input = clicd_json_input(); $enabled = clicd_param_value($input, 'enabled', 'true'); $enabled = filter_var($enabled, FILTER_VALIDATE_BOOLEAN); + $defaultAction = strtoupper(trim((string)clicd_param_value($input, 'default_action', ''))); $rules = clicd_param_value($input, 'rules', '[]'); if (is_string($rules)) { @@ -1461,6 +1462,9 @@ function clicd_firewallUpdate($params) 'enabled' => $enabled, 'rules' => $rules, ]; + if (in_array($defaultAction, ['ACCEPT', 'DROP'], true)) { + $payload['default_action'] = $defaultAction; + } $container = []; $containerId = clicd_container_api_id($params, $container); @@ -1526,6 +1530,7 @@ function clicd_firewall_ajax($params) // update $enabled = clicd_param_value($input, 'enabled', 'true'); $enabled = filter_var($enabled, FILTER_VALIDATE_BOOLEAN); + $defaultAction = strtoupper(trim((string)clicd_param_value($input, 'default_action', ''))); $rules = clicd_param_value($input, 'rules', '[]'); if (is_string($rules)) { @@ -1542,6 +1547,9 @@ function clicd_firewall_ajax($params) 'enabled' => $enabled, 'rules' => $rules, ]; + if (in_array($defaultAction, ['ACCEPT', 'DROP'], true)) { + $payload['default_action'] = $defaultAction; + } $call = clicd_request_debug($params, '/api/v1/containers/' . rawurlencode($containerId) . '/firewall', $payload, 'PUT', 30); $debug[] = $call['debug']; diff --git a/backend/internal/api/firewall.go b/backend/internal/api/firewall.go index 35af498..a3b27b0 100644 --- a/backend/internal/api/firewall.go +++ b/backend/internal/api/firewall.go @@ -4,6 +4,7 @@ import ( "encoding/json" "math/rand" "net/http" + "net/netip" "strconv" "strings" @@ -29,8 +30,9 @@ func getFirewall(w http.ResponseWriter, r *http.Request, id int) { jsonResponse(w, http.StatusOK, APIResponse{ Success: true, Data: map[string]interface{}{ - "enabled": c.FirewallEnabled, - "rules": c.FirewallRules, + "enabled": c.FirewallEnabled, + "default_action": normalizeFirewallDefaultAction(c.FirewallDefaultAction), + "rules": c.FirewallRules, }, }) } @@ -43,17 +45,32 @@ func updateFirewall(w http.ResponseWriter, r *http.Request, id int) { } var req struct { - Enabled *bool `json:"enabled"` - Rules *[]config.FirewallRule `json:"rules"` + Enabled *bool `json:"enabled"` + DefaultAction *string `json:"default_action"` + Rules *[]config.FirewallRule `json:"rules"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"}) return } + oldEnabled := c.FirewallEnabled + oldDefaultAction := c.FirewallDefaultAction + oldRules := append([]config.FirewallRule(nil), c.FirewallRules...) + if req.Enabled != nil { c.FirewallEnabled = *req.Enabled } + if req.DefaultAction != nil { + action := normalizeFirewallDefaultAction(*req.DefaultAction) + if action == "" { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid default action"}) + return + } + c.FirewallDefaultAction = action + } else if strings.TrimSpace(c.FirewallDefaultAction) == "" { + c.FirewallDefaultAction = "DROP" + } if req.Rules != nil { // Validate and assign IDs to new rules rules := *req.Rules @@ -61,9 +78,14 @@ func updateFirewall(w http.ResponseWriter, r *http.Request, id int) { rules[i].Direction = strings.ToLower(strings.TrimSpace(rules[i].Direction)) rules[i].Protocol = strings.ToLower(strings.TrimSpace(rules[i].Protocol)) rules[i].Action = strings.ToUpper(strings.TrimSpace(rules[i].Action)) + rules[i].Network = normalizeFirewallNetwork(rules[i].Network) rules[i].SourceIP = strings.TrimSpace(rules[i].SourceIP) rules[i].Port = strings.TrimSpace(rules[i].Port) + if rules[i].Network == "" { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid network"}) + return + } if rules[i].Direction != "in" && rules[i].Direction != "out" { jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid direction: " + rules[i].Direction}) return @@ -76,11 +98,21 @@ func updateFirewall(w http.ResponseWriter, r *http.Request, id int) { jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid action: " + rules[i].Action}) return } - if rules[i].ID == "" { + if rules[i].SourceIP != "" { + if err := validateFirewallIPSpec(rules[i].SourceIP, rules[i].Network); err != nil { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid IP: " + err.Error()}) + return + } + } + if rules[i].ID == "" || strings.HasPrefix(rules[i].ID, "tmp-") { rules[i].ID = generateFirewallRuleID() } // Validate port spec if rules[i].Port != "" { + if rules[i].Protocol != "tcp" && rules[i].Protocol != "udp" { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Ports are only supported for TCP and UDP rules"}) + return + } if err := validatePortSpec(rules[i].Port); err != nil { jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid port: " + err.Error()}) return @@ -90,11 +122,14 @@ func updateFirewall(w http.ResponseWriter, r *http.Request, id int) { 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 { + c.FirewallEnabled = oldEnabled + c.FirewallDefaultAction = oldDefaultAction + c.FirewallRules = oldRules + _ = lxc.ApplyFirewallRules(id) + config.SaveConfig() jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to apply firewall rules: " + err.Error()}) return } @@ -102,28 +137,54 @@ func updateFirewall(w http.ResponseWriter, r *http.Request, id int) { // If disabled and not running, clean any lingering rules lxc.CleanFirewallRules(id) } + config.SaveConfig() jsonResponse(w, http.StatusOK, APIResponse{ Success: true, Message: "Firewall updated", Data: map[string]interface{}{ - "enabled": c.FirewallEnabled, - "rules": c.FirewallRules, + "enabled": c.FirewallEnabled, + "default_action": normalizeFirewallDefaultAction(c.FirewallDefaultAction), + "rules": c.FirewallRules, }, }) } +func normalizeFirewallDefaultAction(action string) string { + action = strings.ToUpper(strings.TrimSpace(action)) + if action == "ACCEPT" || action == "DROP" { + return action + } + return "" +} + +func normalizeFirewallNetwork(network string) string { + network = strings.ToLower(strings.TrimSpace(network)) + switch network { + case "", "ipv4", "nat4": + return "ipv4" + case "ipv6": + return "ipv6" + case "all", "both": + return "all" + default: + return "" + } +} + func validatePortSpec(port string) error { port = strings.TrimSpace(port) if port == "" { return nil } // Support: "22", "80,443", "8000-9000", "80,443,8000-9000" + partCount := 0 for _, part := range strings.Split(port, ",") { part = strings.TrimSpace(part) if part == "" { - continue + return &portValidationError{port} } + partCount++ if strings.Contains(part, "-") { // Range bounds := strings.SplitN(part, "-", 2) @@ -135,6 +196,9 @@ func validatePortSpec(port string) error { if err != nil || hi < 1 || hi > 65535 { return &portValidationError{part} } + if hi < lo { + return &portValidationError{part} + } } else { p, err := strconv.Atoi(part) if err != nil || p < 1 || p > 65535 { @@ -142,9 +206,48 @@ func validatePortSpec(port string) error { } } } + if partCount > 15 { + return &portValidationError{"too many ports; maximum 15 items per rule"} + } return nil } +func validateFirewallIPSpec(value string, network string) error { + var addr netip.Addr + if strings.Contains(value, "/") { + prefix, err := netip.ParsePrefix(value) + if err != nil { + return err + } + addr = prefix.Addr() + } else { + parsed, err := netip.ParseAddr(value) + if err != nil { + return err + } + addr = parsed + } + switch network { + case "ipv4": + if !addr.Is4() { + return &ipValidationError{"IPv4 rule requires an IPv4 address or CIDR: " + value} + } + case "ipv6": + if !addr.Is6() || addr.Is4In6() { + return &ipValidationError{"IPv6 rule requires an IPv6 address or CIDR: " + value} + } + } + return nil +} + +type ipValidationError struct { + value string +} + +func (e *ipValidationError) Error() string { + return e.value +} + type portValidationError struct { port string } diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 3f46883..bb24edd 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -24,11 +24,12 @@ type PortMapping struct { 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" + Network string `json:"network,omitempty"` // "ipv4", "ipv6", or "all"; empty defaults to "ipv4" + 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"` } @@ -136,7 +137,8 @@ type Container struct { PortMappings []PortMapping `json:"port_mappings"` PortMappingLimit int `json:"port_mapping_limit"` FirewallEnabled bool `json:"firewall_enabled"` - FirewallRules []FirewallRule `json:"firewall_rules"` + FirewallDefaultAction string `json:"firewall_default_action"` + 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 8231817..3a0f098 100644 --- a/backend/internal/config/store_sqlite.go +++ b/backend/internal/config/store_sqlite.go @@ -365,6 +365,7 @@ func ensureSchemaMigrations() error { {"container_public_ipv4s", "prefix_len", "INTEGER"}, {"container_public_ipv4s", "gateway", "TEXT"}, {"containers", "firewall_enabled", "INTEGER NOT NULL DEFAULT 0"}, + {"containers", "firewall_default_action", "TEXT NOT NULL DEFAULT 'DROP'"}, {"containers", "firewall_rules", "TEXT"}, } { if err := ensureColumn(column.table, column.name, column.def); err != nil { @@ -586,8 +587,8 @@ func saveContainers(tx *sql.Tx) error { 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, - firewall_enabled, firewall_rules - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + firewall_enabled, firewall_default_action, 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, @@ -596,7 +597,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), + boolInt(c.FirewallEnabled), normalizeFirewallDefaultAction(c.FirewallDefaultAction), marshalFirewallRules(c.FirewallRules), ); err != nil { return err } @@ -794,7 +795,7 @@ func loadContainers() ([]Container, error) { 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, - firewall_enabled, firewall_rules + firewall_enabled, firewall_default_action, firewall_rules FROM containers ORDER BY id`) if err != nil { return nil, err @@ -805,6 +806,7 @@ func loadContainers() ([]Container, error) { for rows.Next() { var c Container var scheduleEnabled, policyBlocked, firewallEnabled int + var firewallDefaultAction string 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, @@ -815,13 +817,14 @@ func loadContainers() ([]Container, error) { &scheduleEnabled, &c.SnapshotScheduleIntervalHours, &c.SnapshotScheduleTime, &c.SnapshotScheduleLastRun, &c.SnapshotScheduleNextRun, &c.SnapshotScheduleCreatedBy, &policyBlocked, &c.PolicyBlockedReason, &c.PolicyBlockedAt, - &firewallEnabled, &firewallRulesJSON, + &firewallEnabled, &firewallDefaultAction, &firewallRulesJSON, ); err != nil { return nil, err } c.SnapshotScheduleEnabled = scheduleEnabled != 0 c.PolicyBlocked = policyBlocked != 0 c.FirewallEnabled = firewallEnabled != 0 + c.FirewallDefaultAction = normalizeFirewallDefaultAction(firewallDefaultAction) if firewallRulesJSON.Valid && strings.TrimSpace(firewallRulesJSON.String) != "" { _ = json.Unmarshal([]byte(firewallRulesJSON.String), &c.FirewallRules) } @@ -1189,6 +1192,14 @@ func marshalFirewallRules(rules []FirewallRule) interface{} { return string(data) } +func normalizeFirewallDefaultAction(action string) string { + action = strings.ToUpper(strings.TrimSpace(action)) + if action == "ACCEPT" { + return "ACCEPT" + } + return "DROP" +} + 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 2158c8d..fd10d04 100644 --- a/backend/internal/kvm/kvm.go +++ b/backend/internal/kvm/kvm.go @@ -3231,6 +3231,11 @@ func (m *Manager) applyIPv6Runtime(c *config.Container) error { } ensureKVMIPv6NAT66(assignment.Address, uplink) } + if c.Status == "running" { + if err := lxc.ApplyFirewallRules(c.ID); err != nil { + fmt.Printf("Warning: failed to re-apply firewall rules after KVM IPv6 setup for %s: %v\n", c.Name, err) + } + } return nil } @@ -3307,7 +3312,7 @@ func ensureKVMIPv6ForwardRules(ipv6 string, bridge string) { } for _, rule := range rules { check := append([]string{"-C"}, rule...) - add := append([]string{"-I"}, append([]string{rule[0], "1"}, rule[1:]...)...) + add := append([]string{"-A"}, rule...) if exec.Command("ip6tables", check...).Run() != nil { exec.Command("ip6tables", add...).Run() } diff --git a/backend/internal/lxc/ipv6.go b/backend/internal/lxc/ipv6.go index ec54547..b82226d 100644 --- a/backend/internal/lxc/ipv6.go +++ b/backend/internal/lxc/ipv6.go @@ -1610,6 +1610,9 @@ func (m *Manager) ApplyIPv6(id int) error { ensureIPv6NAT66(assignment.Address, uplink) } } + if err := ApplyFirewallRules(c.ID); err != nil { + fmt.Printf("Warning: failed to re-apply firewall rules after IPv6 setup for %s: %v\n", c.Name, err) + } return nil } diff --git a/backend/internal/lxc/portmap.go b/backend/internal/lxc/portmap.go index d3fa0c0..39f68f3 100644 --- a/backend/internal/lxc/portmap.go +++ b/backend/internal/lxc/portmap.go @@ -67,6 +67,10 @@ func (m *Manager) ApplyPortMappings(id int) error { applyIPv4EgressPolicy(c, bridge, subnet, tag) + if err := ApplyFirewallRules(id); err != nil { + return err + } + return nil } @@ -260,8 +264,8 @@ func EnsureForwardRules(bridge string) { break } } - insertArgs := append([]string{"-I", "FORWARD", "1"}, args...) - exec.Command("iptables", insertArgs...).Run() + appendArgs := append([]string{"-A", "FORWARD"}, args...) + exec.Command("iptables", appendArgs...).Run() } } @@ -576,6 +580,9 @@ func CleanFirewallRules(id int) { 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() + cmd = exec.Command("bash", "-c", + fmt.Sprintf("ip6tables -S FORWARD 2>/dev/null | grep 'clicd-%s-fw-' | sed 's/^-A /-D /' | while read rule; do ip6tables $rule; done", tag)) + cmd.CombinedOutput() // Also remove legacy default policy rules (without specific rule ID) for _, suffix := range []string{"default-in", "default-out"} { @@ -584,6 +591,9 @@ func CleanFirewallRules(id int) { "-m", "comment", "--comment", fmt.Sprintf("clicd-%s-fw-%s-%s", tag, suffix, proto), ).CombinedOutput() } + exec.Command("ip6tables", "-D", "FORWARD", + "-m", "comment", "--comment", fmt.Sprintf("clicd-%s-fw-%s", tag, suffix), + ).CombinedOutput() } } @@ -606,42 +616,140 @@ func ApplyFirewallRules(id int) error { if c.IsKVM() { bridge = "virbr0" } - containerIP := c.IP - if containerIP == "" { + containerIP := strings.TrimSpace(c.IP) + containerIPv6s := firewallIPv6Addresses(c) + if containerIP == "" && len(containerIPv6s) == 0 { 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) + defaultAction := normalizeFirewallDefaultAction(c.FirewallDefaultAction) + if defaultAction == "DROP" { + if containerIP != "" { + if err := applyDefaultFirewallPolicy(tag, bridge, containerIP); err != nil { + return err + } + } + if err := applyDefaultFirewallIPv6Policy(tag, bridge, containerIPv6s); err != nil { + return err + } + } - for _, rule := range c.FirewallRules { + for i := len(c.FirewallRules) - 1; i >= 0; i-- { + rule := c.FirewallRules[i] 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) + if containerIP != "" && firewallRuleAppliesToFamily(rule, true) { + if err := applyOneFirewallRule(tag, bridge, containerIP, rule); err != nil { + return fmt.Errorf("failed to apply firewall rule %s for container %d: %w", rule.ID, id, err) + } + } + if len(containerIPv6s) > 0 && firewallRuleAppliesToFamily(rule, false) { + if err := applyOneFirewallIPv6Rule(tag, bridge, containerIPv6s, rule); err != nil { + return fmt.Errorf("failed to apply IPv6 firewall rule %s for container %d: %w", rule.ID, id, err) + } } } return nil } +func normalizeFirewallDefaultAction(action string) string { + action = strings.ToUpper(strings.TrimSpace(action)) + if action == "ACCEPT" { + return "ACCEPT" + } + return "DROP" +} + +func normalizeFirewallNetwork(network string) string { + network = strings.ToLower(strings.TrimSpace(network)) + switch network { + case "", "ipv4", "nat4": + return "ipv4" + case "ipv6": + return "ipv6" + case "all", "both": + return "all" + default: + return "ipv4" + } +} + +func firewallRuleAppliesToFamily(rule config.FirewallRule, ipv4 bool) bool { + network := normalizeFirewallNetwork(rule.Network) + if network == "ipv4" { + return ipv4 + } + if network == "ipv6" { + return !ipv4 + } + if rule.SourceIP == "" { + return true + } + addr := firewallIPSpecAddr(rule.SourceIP) + if !addr.IsValid() { + return true + } + if ipv4 { + return addr.Is4() + } + return addr.Is6() && !addr.Is4In6() +} + +func firewallIPSpecAddr(value string) netip.Addr { + value = strings.TrimSpace(value) + if value == "" { + return netip.Addr{} + } + if strings.Contains(value, "/") { + prefix, err := netip.ParsePrefix(value) + if err != nil { + return netip.Addr{} + } + return prefix.Addr() + } + addr, err := netip.ParseAddr(value) + if err != nil { + return netip.Addr{} + } + return addr +} + +func firewallIPv6Addresses(c *config.Container) []string { + if c == nil { + return nil + } + c.NormalizeNetworkAssignments() + seen := map[string]bool{} + result := []string{} + for _, assignment := range c.IPv6Addresses { + ip := strings.TrimSpace(assignment.Address) + if ip == "" || seen[ip] { + continue + } + if addr, err := netip.ParseAddr(ip); err == nil && addr.Is6() && !addr.Is4In6() { + seen[ip] = true + result = append(result, ip) + } + } + return result +} + 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) + // Direction: in = traffic arriving at container (-o bridge -d containerIP) + // out = traffic leaving container (-i bridge -s containerIP) switch rule.Direction { case "in": - args = append(args, "-i", bridge, "-d", containerIP+"/32") + args = append(args, "-o", bridge, "-d", containerIP+"/32") case "out": - args = append(args, "-o", bridge, "-s", containerIP+"/32") + args = append(args, "-i", bridge, "-s", containerIP+"/32") default: return fmt.Errorf("invalid direction: %s", rule.Direction) } @@ -662,7 +770,7 @@ func applyOneFirewallRule(tag, bridge, containerIP string, rule config.FirewallR 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)) + args = append(args, firewallPortArgs(rule.Port)...) } // Source IP filter (for "out" direction, this matches the remote source; for "in", it matches the sender) @@ -693,51 +801,145 @@ func applyOneFirewallRule(tag, bridge, containerIP string, rule config.FirewallR return nil } +func applyOneFirewallIPv6Rule(tag, bridge string, containerIPs []string, rule config.FirewallRule) error { + for _, containerIP := range containerIPs { + commentTag := fmt.Sprintf("clicd-%s-fw-%s-v6-%s", tag, rule.ID, firewallCommentIPTag(containerIP)) + args := []string{"-I", "FORWARD", "1"} + + switch rule.Direction { + case "in": + args = append(args, "-o", bridge, "-d", containerIP+"/128") + case "out": + args = append(args, "-i", bridge, "-s", containerIP+"/128") + default: + return fmt.Errorf("invalid direction: %s", rule.Direction) + } + + switch rule.Protocol { + case "tcp", "udp": + args = append(args, "-p", rule.Protocol) + case "icmp": + args = append(args, "-p", "ipv6-icmp") + case "all": + default: + return fmt.Errorf("invalid protocol: %s", rule.Protocol) + } + + if rule.Port != "" && (rule.Protocol == "tcp" || rule.Protocol == "udp") { + args = append(args, firewallPortArgs(rule.Port)...) + } + + if rule.SourceIP != "" { + switch rule.Direction { + case "in": + args = append(args, "-s", rule.SourceIP) + case "out": + args = append(args, "-d", rule.SourceIP) + } + } + + action := "DROP" + if rule.Action == "ACCEPT" { + action = "ACCEPT" + } + args = append(args, "-j", action) + args = append(args, "-m", "comment", "--comment", commentTag) + + cmd := exec.Command("ip6tables", args...) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("ip6tables error: %s", string(output)) + } + } + return nil +} + +func firewallPortArgs(port string) []string { + spec := normalizePortSpec(port) + if strings.Contains(spec, ",") { + return []string{"-m", "multiport", "--dports", spec} + } + return []string{"--dport", spec} +} + // normalizePortSpec converts user port input to iptables-compatible port spec. -// "80,443" -> "80,443", "8000-9000" -> "8000:9000", "22" -> "22" +// "80,443" -> "80,443", "8000-9000" -> "8000:9000", "80,443,8000-9000" -> "80,443,8000:9000" 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]) + parts := strings.Split(port, ",") + for i, part := range parts { + part = strings.TrimSpace(part) + if strings.Contains(part, "-") && !strings.Contains(part, ":") { + bounds := strings.SplitN(part, "-", 2) + if len(bounds) == 2 { + part = strings.TrimSpace(bounds[0]) + ":" + strings.TrimSpace(bounds[1]) + } } + parts[i] = part } - return port + return strings.Join(parts, ",") } -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{ +func applyDefaultFirewallPolicy(tag, bridge, containerIP string) error { + defaults := [][]string{ + { "-I", "FORWARD", "1", "-o", bridge, - "-s", containerIP + "/32", - "-p", proto, + "-d", containerIP + "/32", "-j", "DROP", - "-m", "comment", "--comment", fmt.Sprintf("clicd-%s-fw-default-out-%s", tag, proto), - } - cmd := exec.Command("iptables", args...) - cmd.CombinedOutput() + "-m", "comment", "--comment", fmt.Sprintf("clicd-%s-fw-default-in", tag), + }, + { + "-I", "FORWARD", "1", + "-i", bridge, + "-s", containerIP + "/32", + "-j", "DROP", + "-m", "comment", "--comment", fmt.Sprintf("clicd-%s-fw-default-out", tag), + }, } + for _, args := range defaults { + cmd := exec.Command("iptables", args...) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("iptables default firewall error: %s", string(output)) + } + } + return nil +} + +func applyDefaultFirewallIPv6Policy(tag, bridge string, containerIPs []string) error { + for _, containerIP := range containerIPs { + defaults := [][]string{ + { + "-I", "FORWARD", "1", + "-o", bridge, + "-d", containerIP + "/128", + "-j", "DROP", + "-m", "comment", "--comment", fmt.Sprintf("clicd-%s-fw-default-in-v6-%s", tag, firewallCommentIPTag(containerIP)), + }, + { + "-I", "FORWARD", "1", + "-i", bridge, + "-s", containerIP + "/128", + "-j", "DROP", + "-m", "comment", "--comment", fmt.Sprintf("clicd-%s-fw-default-out-v6-%s", tag, firewallCommentIPTag(containerIP)), + }, + } + for _, args := range defaults { + cmd := exec.Command("ip6tables", args...) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("ip6tables default firewall error: %s", string(output)) + } + } + } + return nil +} + +func firewallCommentIPTag(ip string) string { + replacer := strings.NewReplacer(":", "_", ".", "_", "/", "_") + return replacer.Replace(ip) } diff --git a/frontend/src/pages/ApiIntegration.tsx b/frontend/src/pages/ApiIntegration.tsx index 2f70120..0a38877 100644 --- a/frontend/src/pages/ApiIntegration.tsx +++ b/frontend/src/pages/ApiIntegration.tsx @@ -821,10 +821,11 @@ const requestBodySamples: Record> = { 'POST /api/v1/security/check': { container_name: 'example-vm' }, 'PUT /api/v1/containers/{id}/firewall': { enabled: true, + default_action: 'DROP', 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 }, + { id: '', network: 'ipv4', direction: 'in', protocol: 'tcp', port: '22', source_ip: '', action: 'ACCEPT', description: 'Allow SSH over IPv4/NAT4', enabled: true }, + { id: '', network: 'ipv6', direction: 'in', protocol: 'tcp', port: '22', source_ip: '', action: 'ACCEPT', description: 'Allow SSH over IPv6', enabled: true }, + { id: '', network: 'all', direction: 'out', protocol: 'tcp', port: '', source_ip: '', action: 'ACCEPT', description: 'Allow outbound TCP', enabled: true }, ], }, 'PUT /api/v1/security/settings': { auto_shutdown: false }, @@ -1039,10 +1040,11 @@ const responseSamples: Record = { success: true, data: { enabled: true, + default_action: 'DROP', 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 }, + { id: 'a1b2c3d4', network: 'ipv4', direction: 'in', protocol: 'tcp', port: '22', source_ip: '', action: 'ACCEPT', description: 'Allow SSH over IPv4/NAT4', enabled: true }, + { id: 'e5f6g7h8', network: 'ipv6', direction: 'in', protocol: 'tcp', port: '22', source_ip: '', action: 'ACCEPT', description: 'Allow SSH over IPv6', enabled: true }, + { id: 'i9j0k1l2', network: 'all', direction: 'out', protocol: 'tcp', port: '', source_ip: '', action: 'ACCEPT', description: 'Allow outbound TCP', enabled: true }, ], }, }, @@ -1051,10 +1053,11 @@ const responseSamples: Record = { message: 'Firewall updated', data: { enabled: true, + default_action: 'DROP', 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 }, + { id: 'a1b2c3d4', network: 'ipv4', direction: 'in', protocol: 'tcp', port: '22', source_ip: '', action: 'ACCEPT', description: 'Allow SSH over IPv4/NAT4', enabled: true }, + { id: 'e5f6g7h8', network: 'ipv6', direction: 'in', protocol: 'tcp', port: '22', source_ip: '', action: 'ACCEPT', description: 'Allow SSH over IPv6', enabled: true }, + { id: 'i9j0k1l2', network: 'all', direction: 'out', protocol: 'tcp', port: '', source_ip: '', action: 'ACCEPT', description: 'Allow outbound TCP', enabled: true }, ], }, }, @@ -1173,7 +1176,7 @@ function endpointNoteFor(key: string) { 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)。') + notes.push('兼容旧请求:default_action 可不传,不传时保留现有策略;rule.network 可不传,不传按 ipv4 处理。default_action: DROP=未命中规则时拒绝, ACCEPT=未命中规则时放行。network: ipv4=IPv4 NAT/公网 IPv4, ipv6=IPv6, all=同时应用到 IPv4 和 IPv6。NAT 入站规则的 port 填容器内端口,不是宿主机公网端口。') } 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 f498592..40049e2 100644 --- a/frontend/src/pages/ContainerDetail.tsx +++ b/frontend/src/pages/ContainerDetail.tsx @@ -46,6 +46,7 @@ import { HostInfo, TrafficInfo, getEnabledImages, + getFirewall, PortMapping, FirewallRule, reinstallContainer, @@ -166,8 +167,10 @@ export default function ContainerDetail() { const [snapshotScheduleDraft, setSnapshotScheduleDraft] = useState({ intervalHours: 24, time: '03:00' }) const [showFirewall, setShowFirewall] = useState(false) const [firewallEnabled, setFirewallEnabled] = useState(false) + const [firewallDefaultAction, setFirewallDefaultAction] = useState<'ACCEPT' | 'DROP'>('DROP') const [firewallRules, setFirewallRules] = useState([]) const [firewallSaving, setFirewallSaving] = useState(false) + const [firewallMessage, setFirewallMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null) const [editingFirewallRule, setEditingFirewallRule] = useState(null) const [showFirewallEditor, setShowFirewallEditor] = useState(false) @@ -417,29 +420,59 @@ export default function ContainerDetail() { } } - const openFirewall = () => { + const syncFirewallState = (enabled: boolean, defaultAction: 'ACCEPT' | 'DROP', rules: FirewallRule[]) => { + const nextRules = rules.map(r => ({ ...r })) + setFirewallEnabled(enabled) + setFirewallDefaultAction(defaultAction) + setFirewallRules(nextRules) + setContainer(prev => prev ? { + ...prev, + firewall_enabled: enabled, + firewall_default_action: defaultAction, + firewall_rules: nextRules.map(r => ({ ...r })), + } : prev) + } + + const openFirewall = async () => { if (!container) return - setFirewallEnabled(container.firewall_enabled || false) - setFirewallRules(container.firewall_rules ? [...container.firewall_rules.map(r => ({ ...r }))] : []) + syncFirewallState(container.firewall_enabled || false, container.firewall_default_action || 'DROP', container.firewall_rules || []) + setFirewallMessage(null) setShowFirewall(true) + try { + const res = await getFirewall(container.id) + const data = res.data.data + if (data) syncFirewallState(data.enabled, data.default_action || 'DROP', data.rules || []) + } catch (err) { + console.error('Failed to load firewall:', err) + } } const saveFirewall = async () => { if (!container) return setFirewallSaving(true) try { - await updateFirewall(container.id, { enabled: firewallEnabled, rules: firewallRules }) + const res = await updateFirewall(container.id, { enabled: firewallEnabled, default_action: firewallDefaultAction, rules: firewallRules }) + const data = res.data.data + if (data) { + syncFirewallState(data.enabled, data.default_action || 'DROP', data.rules || []) + } + setFirewallMessage({ type: 'success', text: '防火墙设置已保存并应用' }) fetchContainer() } catch (err: any) { - dialog.alert('错误', err?.response?.data?.message || '保存防火墙设置失败') + const message = err?.response?.data?.message || '保存防火墙设置失败' + setFirewallMessage({ type: 'error', text: message }) + dialog.alert('错误', message) } finally { setFirewallSaving(false) } } const addFirewallRule = () => { + const hasIPv4Firewall = (container?.public_ipv4s?.length || 0) > 0 || Math.max(container?.port_mapping_limit || 0, container?.port_mappings?.length || 0) > 0 + const hasIPv6Firewall = !!container?.ipv6 || (container?.ipv6_addresses?.length || 0) > 0 setEditingFirewallRule({ id: '', + network: hasIPv4Firewall ? 'ipv4' : hasIPv6Firewall ? 'ipv6' : 'ipv4', direction: 'in', protocol: 'tcp', port: '', @@ -876,6 +909,20 @@ export default function ContainerDetail() { const mappingLimit = Math.max(container.port_mapping_limit || 0, mappingCount) const hasNATQuota = mappingLimit > 0 const canAddMapping = hasNATQuota && mappingCount < mappingLimit && !isSubUserPolicyBlocked + const hasFirewallIPv4 = hasIndependentIPv4 || hasNATQuota + const firewallNetworkOptions: Array<{ value: NonNullable; label: string }> = [] + if (hasFirewallIPv4) { + firewallNetworkOptions.push({ + value: 'ipv4', + label: hasIndependentIPv4 ? 'IPv4(公网 IPv4)' : 'IPv4(NAT)', + }) + } + if (hasIndependentIPv6) { + firewallNetworkOptions.push({ value: 'ipv6', label: 'IPv6' }) + } + if (hasFirewallIPv4 && hasIndependentIPv6) { + firewallNetworkOptions.push({ value: 'all', label: '全部网络' }) + } const managementUrl = subUser?.access_code ? `${window.location.origin}/login?code=${encodeURIComponent(subUser.access_code)}` : '' @@ -997,7 +1044,7 @@ export default function ContainerDetail() { IPv4 NAT 管理 )} - setShowFirewall(true)} disabled={isSubUserPolicyBlocked}> + 防火墙 @@ -1511,7 +1558,12 @@ export default function ContainerDetail() { {showFirewall && ( { setShowFirewall(false); setShowFirewallEditor(false); setEditingFirewallRule(null) }} wide extra={ !isSubUser && ( - ) @@ -1521,7 +1573,11 @@ export default function ContainerDetail() {
防火墙
-
启用后默认拒绝所有入站和出站流量,仅放行下方规则
+
+ {firewallEnabled + ? (firewallDefaultAction === 'DROP' ? '已启用,未匹配规则的流量将被拒绝' : '已启用,未匹配规则的流量将被放行') + : '未启用时不接管该容器流量'} +
+
+
+
默认动作
+
没有命中下方规则时如何处理
+
+ +
+ +
+
网络范围
+
+ {firewallNetworkOptions.length > 0 + ? `可配置:${firewallNetworkOptions.filter((option) => option.value !== 'all').map((option) => option.label).join('、')}。` + : '当前容器未分配 IPv4 NAT、独立公网 IPv4 或 IPv6,暂无可配置网络。'} + {hasFirewallIPv4 ? ` IPv4 规则覆盖${hasIndependentIPv4 ? '独立公网 IPv4' : 'IPv4 NAT 端口映射'}。` : ''} + {hasNATQuota && !hasIndependentIPv4 ? ' NAT 入站端口按容器内部端口匹配,不是宿主机公网端口。' : ''} + {hasIndependentIPv6 ? ' IPv6 规则覆盖该容器已分配的 IPv6 地址。' : ''} +
+
+ + {firewallMessage && ( +
+ {firewallMessage.text} +
+ )} + {/* Rules table */}
+ @@ -1554,6 +1645,11 @@ export default function ContainerDetail() { + ))} {firewallRules.length === 0 && ( - + )}
状态网络 方向 协议 端口 + + {(rule.network || 'ipv4') === 'ipv6' ? 'IPv6' : (rule.network || 'ipv4') === 'all' ? '全部' : 'IPv4'} + + {rule.direction === 'in' ? '入站' : '出站'} @@ -1571,7 +1667,14 @@ export default function ContainerDetail() { {!isSubUser && (
-
暂无防火墙规则
暂无防火墙规则
@@ -1605,6 +1708,17 @@ export default function ContainerDetail() { {showFirewallEditor && editingFirewallRule && ( { setShowFirewallEditor(false); setEditingFirewallRule(null) }}>
+ + {firewallNetworkOptions.length > 0 ? ( + + ) : ( + + )} + - { + const protocol = e.target.value as FirewallRule['protocol'] + setEditingFirewallRule({ + ...editingFirewallRule, + protocol, + port: protocol === 'tcp' || protocol === 'udp' ? editingFirewallRule.port : '', + }) + }} + className={inputClass} + > - - setEditingFirewallRule({ ...editingFirewallRule, port: e.target.value })} placeholder="如: 22 或 80,443 或 8000-9000" className={inputClass} /> + 容器 22,这里填 22' + : '入站填容器服务端口;留空为全部端口,支持: 22 | 80,443 | 8000-9000') + : '出站填远端目标端口;留空为全部端口,支持: 22 | 80,443 | 8000-9000') + : '端口仅适用于 TCP/UDP'} + > + setEditingFirewallRule({ ...editingFirewallRule, port: e.target.value })} + placeholder={editingFirewallRule.protocol === 'tcp' || editingFirewallRule.protocol === 'udp' ? '如: 22 或 80,443 或 8000-9000' : '当前协议不使用端口'} + disabled={editingFirewallRule.protocol !== 'tcp' && editingFirewallRule.protocol !== 'udp'} + className={`${inputClass} disabled:bg-gray-100 disabled:text-gray-400`} + /> - - setEditingFirewallRule({ ...editingFirewallRule, source_ip: e.target.value })} placeholder="如: 192.168.1.0/24" className={inputClass} /> + + setEditingFirewallRule({ ...editingFirewallRule, source_ip: e.target.value })} + placeholder={(editingFirewallRule.network || 'ipv4') === 'ipv6' ? '如: 2001:db8::/64' : (editingFirewallRule.network || 'ipv4') === 'all' ? '如: 192.168.1.0/24 或 2001:db8::/64' : '如: 192.168.1.0/24'} + className={inputClass} + /> setResourceEdit({ ...resourceEdit, bwMbps: Math.max(0, Number(e.target.value) || 0) })} + + setResourceEdit({ ...resourceEdit, networkDownMbps: Math.max(0, Number(e.target.value) || 0) })} className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
- - setResourceEdit({ ...resourceEdit, ioMbps: Math.max(0, Number(e.target.value) || 0) })} + + setResourceEdit({ ...resourceEdit, networkUpMbps: Math.max(0, Number(e.target.value) || 0) })} + className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" /> +
+
+ + setResourceEdit({ ...resourceEdit, ioReadMbps: Math.max(0, Number(e.target.value) || 0) })} + className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" /> +
+
+ + setResourceEdit({ ...resourceEdit, ioWriteMbps: Math.max(0, Number(e.target.value) || 0) })} className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
@@ -2459,6 +2488,32 @@ function clampResourceInt(value: number, min: number, max?: number, fallback = m return Math.min(Math.max(next, min), max ?? next) } +function resourceLimitValue(value?: number, fallback?: number) { + return Math.max(0, Number(value || fallback || 0)) +} + +function symmetricLimit(a: number, b: number) { + const left = resourceLimitValue(a) + const right = resourceLimitValue(b) + if (left === right) return left + if (left === 0) return right + if (right === 0) return left + return Math.min(left, right) +} + +function directionUsagePercent(bytesPerSecond: number, limit: number, bytesPerLimitUnit: number, fallbackBytesPerSecond: number) { + const denominator = limit > 0 ? limit * bytesPerLimitUnit : fallbackBytesPerSecond + return denominator > 0 ? clamp((bytesPerSecond / denominator) * 100) : 0 +} + +function formatLimit(value: number, unit: string) { + return value > 0 ? `${value} ${unit}` : '不限制' +} + +function formatDirectionalLimit(firstLabel: string, firstValue: number, secondLabel: string, secondValue: number, unit: string) { + return `${firstLabel} ${formatLimit(firstValue, unit)} / ${secondLabel} ${formatLimit(secondValue, unit)}` +} + function toChartPoints>(history: MetricPoint[], key: T): ChartPoint[] { return history.map((point) => ({ ts: point.ts, value: Number(point[key]) || 0 })) } diff --git a/frontend/src/pages/Containers.tsx b/frontend/src/pages/Containers.tsx index 352b855..3815218 100644 --- a/frontend/src/pages/Containers.tsx +++ b/frontend/src/pages/Containers.tsx @@ -704,6 +704,8 @@ function toPlaceholder(cfg: CreateContainerRequest): DisplayContainer { ram_mb: cfg.ram_mb, disk_gb: cfg.disk_gb, network_bw_mbps: cfg.network_bw_mbps, + network_down_mbps: cfg.network_down_mbps, + network_up_mbps: cfg.network_up_mbps, monthly_traffic_gb: cfg.monthly_traffic_gb, traffic_mode: cfg.traffic_mode || 'total', traffic_in_gb: cfg.traffic_in_gb || 0, @@ -712,6 +714,8 @@ function toPlaceholder(cfg: CreateContainerRequest): DisplayContainer { traffic_used_tx: 0, traffic_reset_date: '', io_speed_mbps: cfg.io_speed_mbps, + io_read_mbps: cfg.io_read_mbps, + io_write_mbps: cfg.io_write_mbps, status: 'creating', ip: '', public_ipv4s: [], diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 99b4c42..b6a8003 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -80,6 +80,8 @@ export interface Container { ram_mb: number disk_gb: number network_bw_mbps: number + network_down_mbps: number + network_up_mbps: number monthly_traffic_gb: number traffic_mode: string traffic_in_gb: number @@ -88,6 +90,8 @@ export interface Container { traffic_used_tx: number traffic_reset_date: string io_speed_mbps: number + io_read_mbps: number + io_write_mbps: number status: string ip: string public_ipv4s?: PublicIPv4Assignment[] @@ -138,11 +142,15 @@ export interface CreateContainerRequest { ram_mb: number disk_gb: number network_bw_mbps: number + network_down_mbps: number + network_up_mbps: number monthly_traffic_gb: number traffic_mode: string traffic_in_gb: number traffic_out_gb: number io_speed_mbps: number + io_read_mbps: number + io_write_mbps: number extra_ports: number[] port_mapping_count: number assign_nat?: boolean @@ -488,8 +496,12 @@ export const updateTrafficLimit = (id: ContainerIdentifier, data: { export const updateResourceLimit = (id: ContainerIdentifier, data: { vcpu: number ram_mb: number - io_speed_mbps: number - network_bw_mbps: number + io_speed_mbps?: number + io_read_mbps?: number + io_write_mbps?: number + network_bw_mbps?: number + network_down_mbps?: number + network_up_mbps?: number }) => api.put(`/containers/${id}/resource-limit`, data) diff --git a/frontend/src/utils/i18n.ts b/frontend/src/utils/i18n.ts index f445b0d..1c650c7 100644 --- a/frontend/src/utils/i18n.ts +++ b/frontend/src/utils/i18n.ts @@ -92,6 +92,22 @@ const exact: Record = { '创建时间': 'Created At', '网络速率': 'Network Speed', 'IO 速度': 'IO Speed', + '下行带宽': 'Download Bandwidth', + '上行带宽': 'Upload Bandwidth', + '读取 IO': 'Read IO', + '写入 IO': 'Write IO', + '下行带宽 (Mbps)': 'Download Bandwidth (Mbps)', + '上行带宽 (Mbps)': 'Upload Bandwidth (Mbps)', + '读取 IO (MB/s)': 'Read IO (MB/s)', + '写入 IO (MB/s)': 'Write IO (MB/s)', + '下行带宽 (Mbps,0=不限制)': 'Download Bandwidth (Mbps, 0=unlimited)', + '上行带宽 (Mbps,0=不限制)': 'Upload Bandwidth (Mbps, 0=unlimited)', + '读取 IO (MB/s,0=不限制)': 'Read IO (MB/s, 0=unlimited)', + '写入 IO (MB/s,0=不限制)': 'Write IO (MB/s, 0=unlimited)', + '限速占用': 'Limit Usage', + '支持独立限制上行/下行带宽和读/写 I/O 操作。': 'Supports independent upload/download bandwidth limits and read/write I/O limits.', + '支持独立限制上行/下行带宽和读/写 I/O 操作。network_bw_mbps 与 io_speed_mbps 为旧版对称限制兼容别名,建议新对接使用 network_down_mbps、network_up_mbps、io_read_mbps、io_write_mbps。': 'Supports independent upload/download bandwidth limits and read/write I/O limits. network_bw_mbps and io_speed_mbps are deprecated symmetric compatibility aliases. New integrations should use network_down_mbps, network_up_mbps, io_read_mbps, and io_write_mbps.', + '支持独立限制下行/上行带宽和读取/写入 I/O。未传字段保持原值,显式传 0 表示该方向不限速;network_bw_mbps 与 io_speed_mbps 为旧版对称限制兼容别名。': 'Supports independent download/upload bandwidth limits and read/write I/O limits. Omitted fields keep their current values, and explicitly passing 0 makes that direction unlimited. network_bw_mbps and io_speed_mbps are deprecated symmetric compatibility aliases.', '月流量': 'Monthly Traffic', '统计信息': 'Statistics', 'CPU 使用率': 'CPU Usage', @@ -578,7 +594,11 @@ const exact: Record = { '更新 Key': 'Update Key', '删除 Key': 'Delete Key', '总览': 'Overview', + 'NAT/IPv4/IPv6 路由': 'NAT / IPv4 / IPv6 Routing', 'NAT/IPv6 路由': 'NAT / IPv6 Routing', + '更新公网 IPv4/IPv6 池': 'Update Public IPv4 / IPv6 Pools', + '扫描公网 IPv4 段': 'Scan Public IPv4 Prefixes', + '公网 IPv4/IPv6 池': 'Public IPv4 / IPv6 Pools', '任务队列': 'Task Queue', '任务列表': 'Task List', '操作记录': 'audit records', @@ -588,6 +608,7 @@ const exact: Record = { '管理员接口': 'Admin API', '控制面板统计': 'Dashboard Stats', '立即安全检查': 'Run Security Check', + '路由配置': 'Routing Configuration', '返回响应样例': 'Response Example', '请求参数': 'Request Parameters', '响应字段': 'Response Fields', @@ -616,11 +637,14 @@ const exact: Record = { '添加端口映射': 'Add Port Mapping', '更新端口映射': 'Update Port Mapping', '删除端口映射': 'Delete Port Mapping', + '获取防火墙设置': 'Get Firewall Settings', + '更新防火墙设置': 'Update Firewall Settings', '快照总览': 'Snapshot Overview', '容器快照': 'Container Snapshots', '计划快照': 'Scheduled Snapshots', '快照配额': 'Snapshot Quota', '模板列表': 'Template List', + '镜像管理列表': 'Image Management List', '取消镜像下载': 'Cancel Image Download', '启用/禁用镜像': 'Enable / Disable Image', '安全连接日志': 'Security Connection Logs', @@ -650,7 +674,6 @@ const exact: Record = { 'WebVNC 票据': 'WebVNC Ticket', '容器列表(兼容 POST 写法)': 'Container List (compatible POST form)', '调整到期时间': 'Adjust Expiration Time', - '镜像管理列表': 'Image Management List', '批量创建容器': 'Batch Create Containers', '创建 WebSSH 票据': 'Create WebSSH Ticket', '创建 WebVNC 票据': 'Create WebVNC Ticket', @@ -677,6 +700,12 @@ const exact: Record = { 'CI/CD、计费系统、自动化脚本': 'CI/CD, billing systems, automation scripts', 'SWAP 已调整为 16384 MB': 'SWAP adjusted to 16384 MB', '***60秒有效票据***': '***60-second valid ticket***', + 'Linux 创建支持 ssh_auth_mode=auto_password|password|key;公网 IPv4、IPv6 与 NAT 可通过 assign_nat、assign_ipv4、assign_ipv6 组合使用。': 'Linux container creation supports ssh_auth_mode=auto_password|password|key. Public IPv4, IPv6, and NAT can be configured with assign_nat, assign_ipv4, and assign_ipv6.', + '重装支持 ssh_auth_mode=keep|auto_password|password|key;keep 仅用于重装,未传 SSH 字段时保持原有行为。': 'Reinstall supports ssh_auth_mode=keep|auto_password|password|key. keep is only for reinstall requests; if SSH fields are omitted, the existing behavior is kept.', + '批量创建的单个 containers[] 项支持与 POST /api/v1/containers 相同的网络和 SSH 认证字段。': 'Each containers[] item in batch creation supports the same network and SSH authentication fields as POST /api/v1/containers.', + 'action=reinstall 时可追加 template_id、ssh_auth_mode、ssh_password、ssh_public_key;其他 action 会忽略这些重装字段。': 'When action=reinstall, you can include template_id, ssh_auth_mode, ssh_password, and ssh_public_key. Other actions ignore these reinstall fields.', + '更新公网地址池需要 routing:write;已分配给容器的地址不能从池中移除。': 'Updating public address pools requires routing:write. Addresses already assigned to containers cannot be removed from the pool.', + '扫描公网 IPv4 段需要 routing:write;verify=true 时会尝试校验地址可用性。': 'Scanning public IPv4 prefixes requires routing:write. When verify=true, the API also attempts to check address availability.', 'WebVNC 仅适用于 KVM 虚拟机;LXC 容器会返回 VNC console is only available for KVM VMs。': 'WebVNC only applies to KVM VMs; LXC containers return "VNC console is only available for KVM VMs".', '该接口会进入任务队列,请随后调用 GET /api/v1/tasks 查看执行状态。': 'This API enters the task queue. Call GET /api/v1/tasks afterward to check execution status.', '样例中的密钥、密码和票据已脱敏;创建类接口的完整密钥只在创建响应中出现一次。': 'Keys, passwords, and tickets in examples are masked. Full secrets from create APIs appear only once in the creation response.', @@ -943,7 +972,12 @@ const replacements: Array<[RegExp, string]> = [ [/当前证书:/g, 'Current certificate: '], [/第\s*(\d+)\s*页/g, 'Page $1'], [/入\s*([^/,]+)\s*\/\s*出\s*([^,]+),累计\s*(.+)$/g, 'In $1 / Out $2, total $3'], + [/入\s*([^/,]+)\s*\/\s*出\s*([^,]+),限速占用\s*([^,]+),累计\s*(.+)$/g, 'In $1 / Out $2, limit usage $3, total $4'], + [/下\s*([^/]+)\s*\/\s*上\s*(.+)$/g, 'Down $1 / Up $2'], + [/下行\s*([^/]+)\s*\/\s*上行\s*(.+)$/g, 'Download $1 / Upload $2'], + [/读取\s*([^/]+)\s*\/\s*写入\s*(.+)$/g, 'Read $1 / Write $2'], [/读\s*([^/,]+)\s*\/\s*写\s*([^,]+),累计\s*([^,]+),容量\s*(.+)$/g, 'Read $1 / Write $2, total $3, capacity $4'], + [/读\s*([^/,]+)\s*\/\s*写\s*([^,]+),限速占用\s*([^,]+),累计\s*([^,]+),容量\s*(.+)$/g, 'Read $1 / Write $2, limit usage $3, total $4, capacity $5'], [/(.+?),筛选后\s*(\d+)\s*items/g, '$1, filtered $2 items'], [/(.+?),已选\s*(\d+)\s*items/g, '$1, selected $2 items'], [/将创建\s*(\d+)\s*个容器:(.+?)\s*至\s*(.+)$/g, 'Will create $1 containers: $2 to $3'], From 55c7a9796c5976d375dc90762c94fa144a3e9379 Mon Sep 17 00:00:00 2001 From: MengMengCode <227010654+MengMengCode@users.noreply.github.com> Date: Fri, 12 Jun 2026 15:59:24 +0800 Subject: [PATCH 6/9] chore: add version badge to README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 3f7c08d..8b77b23 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ CLICD

-

CLICD

+

CLICD v1.1.18

Go From cbe93393161c7d63082acb39d04448e9fb3ed7f2 Mon Sep 17 00:00:00 2001 From: MengMengCode <227010654+MengMengCode@users.noreply.github.com> Date: Fri, 12 Jun 2026 16:01:52 +0800 Subject: [PATCH 7/9] release: v1.1.19 --- backend/internal/version/version.go | 2 +- frontend/package.json | 2 +- frontend/src/pages/Login.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/internal/version/version.go b/backend/internal/version/version.go index a9f1a97..0eeb0f7 100644 --- a/backend/internal/version/version.go +++ b/backend/internal/version/version.go @@ -1,7 +1,7 @@ package version var ( - Version = "1.1.18" + Version = "1.1.19" Repo = "MengMengCode/CLICD" ) diff --git a/frontend/package.json b/frontend/package.json index 016aa1d..de64020 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "clicd-frontend", "private": true, - "version": "1.1.18", + "version": "1.1.19", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index 68c01dd..1e5197d 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -128,7 +128,7 @@ export default function Login() { -

CLICD v1.1.18

+

CLICD v1.1.19

) From 3a65d5d24a8bc876f299376d3d60698fc66075c3 Mon Sep 17 00:00:00 2001 From: Meng Meng Date: Fri, 12 Jun 2026 16:03:38 +0800 Subject: [PATCH 8/9] Remove version number from README title Removed version number from the title in README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 8b77b23..893cf98 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ CLICD

-

CLICD v1.1.18

+

CLICD

Go From 86f0d079ab3ebda2b79fa0b9d1750acb5affd0f6 Mon Sep 17 00:00:00 2001 From: MengMengCode <227010654+MengMengCode@users.noreply.github.com> Date: Fri, 12 Jun 2026 21:55:15 +0800 Subject: [PATCH 9/9] update docs --- docs/en/features/api.md | 204 ++++++++++++++++++++++++++++++++++++++-- docs/features/api.md | 202 +++++++++++++++++++++++++++++++++++++-- 2 files changed, 387 insertions(+), 19 deletions(-) diff --git a/docs/en/features/api.md b/docs/en/features/api.md index 7d2512a..137f789 100644 --- a/docs/en/features/api.md +++ b/docs/en/features/api.md @@ -53,7 +53,11 @@ Create container example: "ssh_auth_mode": "auto_password", "ssh_password": "", "ssh_public_key": "", - "expires_at": "" + "expires_at": "", + "network_down_mbps": 100, + "network_up_mbps": 50, + "io_read_mbps": 120, + "io_write_mbps": 80 } ``` @@ -71,6 +75,12 @@ Field notes: | `ssh_auth_mode` | Linux creation supports `auto_password`, `password`, and `key`; reinstall also supports `keep`. | | `ssh_password` | Custom password for `password` mode. It must be 8-64 characters, include letters and digits, and contain no whitespace. | | `ssh_public_key` | One-line SSH public key for `key` mode. | +| `network_down_mbps` | Optional container download/downlink bandwidth limit in Mbps. `0` means unlimited. | +| `network_up_mbps` | Optional container upload/uplink bandwidth limit in Mbps. `0` means unlimited. | +| `io_read_mbps` | Optional disk read limit in MB/s. `0` means unlimited. | +| `io_write_mbps` | Optional disk write limit in MB/s. `0` means unlimited. | +| `network_bw_mbps` | Legacy-compatible field. Sets symmetric downlink/uplink bandwidth; new integrations should prefer the split fields. | +| `io_speed_mbps` | Legacy-compatible field. Sets symmetric read/write I/O limits; new integrations should prefer the split fields. | Reinstall example: @@ -85,6 +95,102 @@ Reinstall example: `keep` is only for reinstall and keeps the current SSH password. Windows KVM images ignore Linux SSH public key fields. +## Resource and Traffic Limits + +`PUT /api/v1/containers/{id}/resource-limit` supports partial updates. Fields omitted from the request remain unchanged. + +```json +{ + "vcpu": 2, + "ram_mb": 1024, + "network_down_mbps": 100, + "network_up_mbps": 50, + "io_read_mbps": 120, + "io_write_mbps": 80 +} +``` + +Legacy `network_bw_mbps` and `io_speed_mbps` are still accepted. They mean symmetric downlink/uplink bandwidth and symmetric read/write I/O limits. New integrations should use the split fields to control download/upload and read/write independently. + +`PUT /api/v1/containers/{id}/traffic-limit` request body: + +```json +{ + "traffic_mode": "total", + "monthly_traffic_gb": 1024, + "traffic_in_gb": 0, + "traffic_out_gb": 0 +} +``` + +| Field | Description | +| --- | --- | +| `traffic_mode` | Traffic limit mode. Common values are `total` for a shared total limit and `split` for separate inbound/outbound limits. | +| `monthly_traffic_gb` | Monthly total traffic quota for `total` mode, in GB. `0` means unlimited. | +| `traffic_in_gb` | Monthly inbound quota for `split` mode, in GB. `0` means unlimited. | +| `traffic_out_gb` | Monthly outbound quota for `split` mode, in GB. `0` means unlimited. | + +## Container Firewall + +Read container firewall settings with `GET /api/v1/containers/{id}/firewall` and update them with `PUT /api/v1/containers/{id}/firewall`. Updates are applied immediately when the container is running. + +Update example: + +```json +{ + "enabled": true, + "default_action": "DROP", + "rules": [ + { + "direction": "in", + "protocol": "tcp", + "action": "ACCEPT", + "network": "ipv4", + "source_ip": "203.0.113.0/24", + "port": "22,80,443", + "description": "allow admin and web" + } + ] +} +``` + +| Field | Description | +| --- | --- | +| `enabled` | Whether the container firewall is enabled. | +| `default_action` | Default action: `ACCEPT` or `DROP`. | +| `rules[].id` | Optional. Omit for new rules and the backend will generate one. | +| `rules[].direction` | Direction: `in` or `out`. | +| `rules[].protocol` | Protocol: `tcp`, `udp`, `icmp`, or `all`. | +| `rules[].action` | Action: `ACCEPT` or `DROP`. | +| `rules[].network` | Network type: `ipv4`, `ipv6`, or `all`. | +| `rules[].source_ip` | Optional source IP, CIDR, or address range. | +| `rules[].port` | Optional. Supported only for `tcp`/`udp`; examples: `22`, `80,443`, or `8000-9000`. | +| `rules[].description` | Optional note. | + +## API Key Create and Update + +`POST /api/v1/api-keys` and `PATCH /api/v1/api-keys/{id}` use the same field shape. `name` is required when creating a key; updates overwrite the fields you send. + +```json +{ + "name": "Automation", + "ip_whitelist": "198.51.100.23,203.0.113.0/24", + "scopes": ["dashboard:read", "container:read", "container:power"], + "expires_at": "2026-12-31 23:59:59", + "disabled": false, + "container_uuids": ["00000000-0000-4000-8000-000000000005"] +} +``` + +| Field | Description | +| --- | --- | +| `name` | API key name. Required when creating a key. | +| `ip_whitelist` | Optional allowed source IPs/CIDRs, comma-separated. Empty means no IP restriction. | +| `scopes` | Optional permission scopes. If omitted, the default read-only scopes are used. `*` grants all permissions. | +| `expires_at` | Optional expiration time. Empty means no expiration. | +| `disabled` | Whether this key is disabled. | +| `container_uuids` | Optional container allowlist that limits the key to specific containers. | + ## Python Example Fetch containers: @@ -140,6 +246,7 @@ print(resp.json()) | --- | --- | --- | | GET | `/api/v1/dashboard` | Dashboard statistics | | GET | `/api/v1/host-info` | Host resources | +| GET | `/api/v1/host-report` | Host inspection report | | GET | `/api/v1/routing` | NAT/IPv4/IPv6 routing | | PUT | `/api/v1/routing` | Update public IPv4/IPv6 pools | | POST | `/api/v1/routing/ipv4-scan` | Scan a public IPv4 segment | @@ -151,10 +258,11 @@ print(resp.json()) | Method | Path | Description | | --- | --- | --- | -| GET | `/api/v1/containers` | Container list | +| GET | `/api/v1/containers` | Container list (recommended) | +| GET | `/api/v1/containers/list` | Compatible GET form for container list | | POST | `/api/v1/containers/list` | Compatible POST form for container list | | POST | `/api/v1/containers` | Create container | -| GET | `/api/v1/containers/{id|uuid|name}` | Container details | +| GET | `/api/v1/containers/{id\|uuid\|name}` | Container details | | POST | `/api/v1/containers/{id}/start` | Start | | POST | `/api/v1/containers/{id}/stop` | Stop | | POST | `/api/v1/containers/{id}/restart` | Restart | @@ -173,10 +281,12 @@ print(resp.json()) | Method | Path | Description | | --- | --- | --- | -| GET | `/api/v1/containers/{id}/random-port` | Random available port | +| GET | `/api/v1/containers/{id}/random-port` | Random available port; accepts `host_ip` to check a specific host IP | | POST | `/api/v1/containers/{id}/port-mappings` | Add port mapping | | PUT | `/api/v1/containers/{id}/port-mappings/{index}` | Update port mapping | | DELETE | `/api/v1/containers/{id}/port-mappings/{index}` | Delete port mapping | +| GET | `/api/v1/containers/{id}/firewall` | Get container firewall settings | +| PUT | `/api/v1/containers/{id}/firewall` | Update container firewall settings | | GET | `/api/v1/snapshots` | Snapshot overview | | GET | `/api/v1/containers/{id}/snapshots` | Container snapshots | | POST | `/api/v1/containers/{id}/snapshots` | Create snapshot | @@ -191,6 +301,7 @@ print(resp.json()) | --- | --- | --- | | GET | `/api/v1/templates` | Template list | | GET | `/api/v1/images` | Image management list | +| GET | `/api/v1/images/enabled` | Enabled and downloaded images; supports `type=lxc\|kvm` | | POST | `/api/v1/images/download` | Download image | | POST | `/api/v1/images/cancel` | Cancel image download | | DELETE | `/api/v1/images/delete` | Delete image cache | @@ -203,6 +314,12 @@ print(resp.json()) | PUT | `/api/v1/security/settings` | Update security settings | | GET | `/api/v1/swap` | Swap information | | POST | `/api/v1/swap` | Adjust Swap | +| GET | `/api/v1/language` | Current panel language | +| POST/PUT | `/api/v1/language` | Update panel language | +| GET | `/api/v1/ssl` | SSL settings (requires admin permission / `admin:access`) | +| PUT | `/api/v1/ssl` | Update SSL settings (requires admin permission / `admin:access`) | +| GET | `/api/v1/webssh-origins` | WebSSH Origin allowlist (requires admin permission / `admin:access`) | +| PUT | `/api/v1/webssh-origins` | Update WebSSH Origin allowlist (requires admin permission / `admin:access`) | | POST | `/api/v1/batch-create` | Batch create containers | | POST | `/api/v1/batch-action` | Batch power action, delete, or reinstall | | POST | `/api/v1/ssh-ticket` | Create WebSSH ticket | @@ -255,6 +372,16 @@ The samples below are grouped by endpoint path. Resource numbers, task IDs, cont "load": { "load1": 0.01, "load5": 0.03, "load15": 0.01 } } }, + "GET /api/v1/host-report": { + "success": true, + "data": { + "generated_at": "2026-06-12 10:00:00", + "summary": { "status": "ok", "warnings": 0 }, + "host": { "hostname": "node-1", "kernel": "6.8.0" }, + "resources": { "cpu_cores": 8, "ram_total_mb": 31825, "disk_total_gb": 1750.49 }, + "network": { "public_ipv4": "203.0.113.10", "public_ipv6": "2001:db8:100::2" } + } + }, "GET /api/v1/routing": { "success": true, "data": { @@ -331,6 +458,10 @@ The samples below are grouped by endpoint path. Resource numbers, task IDs, cont "vcpu": 1, "ram_mb": 512, "disk_gb": 10, + "network_down_mbps": 100, + "network_up_mbps": 50, + "io_read_mbps": 120, + "io_write_mbps": 80, "status": "running", "ip": "10.0.0.10", "ipv6": "2001:db8:100::1005", @@ -343,6 +474,12 @@ The samples below are grouped by endpoint path. Resource numbers, task IDs, cont } ] }, + "GET /api/v1/containers/list": { + "success": true, + "data": [ + { "id": 5, "uuid": "00000000-0000-4000-8000-000000000005", "name": "example-vm", "status": "running", "ip": "10.0.0.10" } + ] + }, "POST /api/v1/containers/list": { "success": true, "data": [ @@ -410,7 +547,7 @@ The samples below are grouped by endpoint path. Resource numbers, task IDs, cont "success": true, "data": { "mode": "total", - "limit_gb": 0, + "limit_gb": 1024, "in_limit_gb": 0, "out_limit_gb": 0, "total_used_bytes": 142082, @@ -453,7 +590,7 @@ The samples below are grouped by endpoint path. Resource numbers, task IDs, cont ```json { - "GET /api/v1/containers/{id}/random-port": { + "GET /api/v1/containers/{id}/random-port?host_ip=203.0.113.10": { "success": true, "data": { "port": 61320 } }, @@ -474,6 +611,21 @@ The samples below are grouped by endpoint path. Resource numbers, task IDs, cont "success": true, "data": [] }, + "GET /api/v1/containers/{id}/firewall": { + "success": true, + "data": { + "enabled": true, + "default_action": "DROP", + "rules": [ + { "id": "a1b2c3d4", "direction": "in", "protocol": "tcp", "action": "ACCEPT", "network": "ipv4", "source_ip": "203.0.113.0/24", "port": "22,80,443", "description": "allow admin and web" } + ] + } + }, + "PUT /api/v1/containers/{id}/firewall": { + "success": true, + "message": "Firewall updated", + "data": { "enabled": true, "default_action": "DROP", "rules": [] } + }, "GET /api/v1/snapshots": { "success": true, "data": null @@ -539,6 +691,12 @@ The samples below are grouped by endpoint path. Resource numbers, task IDs, cont { "id": "ubuntu-noble", "name": "Ubuntu 24.04", "type": "lxc", "downloaded": true, "enabled": true, "downloading": false, "progress": 0, "size_bytes": 135005452 } ] }, + "GET /api/v1/images/enabled?type=lxc": { + "success": true, + "data": [ + { "id": "ubuntu-noble", "name": "Ubuntu 24.04", "distro": "ubuntu", "release": "noble", "arch": "amd64", "variant": "default", "description": "Ubuntu 24.04 LTS", "type": "lxc" } + ] + }, "POST /api/v1/images/download": { "success": true, "message": "Already downloaded" @@ -585,9 +743,35 @@ The samples below are grouped by endpoint path. Resource numbers, task IDs, cont }, "POST /api/v1/swap": { "success": true, - "message": "SWAP 已调整为 16384 MB", + "message": "SWAP adjusted to 16384 MB", "data": { "total_mb": 16383, "used_mb": 0, "free_mb": 16383, "enabled": true, "swap_file": "/swapfile" } }, + "GET /api/v1/language": { + "success": true, + "data": { "language": "zh" } + }, + "PUT /api/v1/language": { + "success": true, + "data": { "language": "en" } + }, + "GET /api/v1/ssl": { + "success": true, + "data": { "enabled": true, "mode": "self-signed", "target": "panel.example.com", "detected_host": "panel.example.com", "needs_restart": false } + }, + "PUT /api/v1/ssl": { + "success": true, + "message": "SSL settings saved", + "data": { "enabled": true, "mode": "self-signed", "target": "panel.example.com", "needs_restart": true } + }, + "GET /api/v1/webssh-origins": { + "success": true, + "data": { "origins": ["https://panel.example.com"], "current_origin": "https://panel.example.com" } + }, + "PUT /api/v1/webssh-origins": { + "success": true, + "message": "Origin allowlist saved", + "data": { "origins": ["https://panel.example.com"], "current_origin": "https://panel.example.com" } + }, "POST /api/v1/batch-create": { "success": true, "data": ["task-12"] @@ -654,17 +838,17 @@ The samples below are grouped by endpoint path. Resource numbers, task IDs, cont "GET /api/v1/api-keys": { "success": true, "data": [ - { "id": "c271023f", "name": "Test", "prefix": "clicd_sk_dd9d...", "ip_whitelist": "", "created_at": "2026-06-08 15:44:40", "last_used": "2026-06-08 15:46:10", "scopes": ["*"], "last_used_ip": "198.51.100.23" } + { "id": "c271023f", "name": "Test", "prefix": "clicd_sk_dd9d...", "ip_whitelist": "", "created_at": "2026-06-08 15:44:40", "last_used": "2026-06-08 15:46:10", "scopes": ["*"], "expires_at": "", "disabled": false, "container_uuids": [], "last_used_ip": "198.51.100.23" } ] }, "POST /api/v1/api-keys": { "success": true, "message": "API key created. Save this key now - it won't be shown again.", - "data": { "id": "a1b2c3d4", "name": "Automation", "key": "clicd_sk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", "prefix": "clicd_sk_xxxx...", "scopes": ["dashboard:read", "container:read"] } + "data": { "id": "a1b2c3d4", "name": "Automation", "key": "clicd_sk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", "prefix": "clicd_sk_xxxx...", "ip_whitelist": "198.51.100.23", "scopes": ["dashboard:read", "container:read"], "expires_at": "2026-12-31 23:59:59", "disabled": false, "container_uuids": ["00000000-0000-4000-8000-000000000005"] } }, "PATCH /api/v1/api-keys/{id}": { "success": true, - "data": { "id": "a1b2c3d4", "name": "Automation", "prefix": "clicd_sk_xxxx...", "scopes": ["dashboard:read", "container:read"], "disabled": false } + "data": { "id": "a1b2c3d4", "name": "Automation", "prefix": "clicd_sk_xxxx...", "scopes": ["dashboard:read", "container:read"], "expires_at": "2026-12-31 23:59:59", "disabled": false, "container_uuids": ["00000000-0000-4000-8000-000000000005"] } }, "DELETE /api/v1/api-keys/{id}": { "success": true, diff --git a/docs/features/api.md b/docs/features/api.md index b247a4b..86ef1cd 100644 --- a/docs/features/api.md +++ b/docs/features/api.md @@ -53,7 +53,11 @@ curl -H "Authorization: Bearer YOUR_API_KEY" https://panel.example.com/api/v1/da "ssh_auth_mode": "auto_password", "ssh_password": "", "ssh_public_key": "", - "expires_at": "" + "expires_at": "", + "network_down_mbps": 100, + "network_up_mbps": 50, + "io_read_mbps": 120, + "io_write_mbps": 80 } ``` @@ -71,6 +75,12 @@ curl -H "Authorization: Bearer YOUR_API_KEY" https://panel.example.com/api/v1/da | `ssh_auth_mode` | Linux 创建支持 `auto_password`、`password`、`key`;重装额外支持 `keep`。 | | `ssh_password` | `password` 模式下的自定义密码;8-64 位,至少包含字母和数字,不能包含空白字符。 | | `ssh_public_key` | `key` 模式下的一行 SSH 公钥。 | +| `network_down_mbps` | 可选;容器下行/下载带宽限制,单位 Mbps,`0` 表示不限制。 | +| `network_up_mbps` | 可选;容器上行/上传带宽限制,单位 Mbps,`0` 表示不限制。 | +| `io_read_mbps` | 可选;磁盘读取限速,单位 MB/s,`0` 表示不限制。 | +| `io_write_mbps` | 可选;磁盘写入限速,单位 MB/s,`0` 表示不限制。 | +| `network_bw_mbps` | 兼容旧字段;同时设置上下行对称带宽,新接入推荐使用拆分字段。 | +| `io_speed_mbps` | 兼容旧字段;同时设置读写对称 IO 限速,新接入推荐使用拆分字段。 | 重装示例: @@ -85,6 +95,102 @@ curl -H "Authorization: Bearer YOUR_API_KEY" https://panel.example.com/api/v1/da `keep` 仅用于重装,表示沿用当前 SSH 密码。Windows KVM 镜像会忽略 Linux SSH 公钥相关字段。 +## 资源限制与流量限制 + +`PUT /api/v1/containers/{id}/resource-limit` 支持按字段局部更新;未传的字段保持不变。 + +```json +{ + "vcpu": 2, + "ram_mb": 1024, + "network_down_mbps": 100, + "network_up_mbps": 50, + "io_read_mbps": 120, + "io_write_mbps": 80 +} +``` + +旧版 `network_bw_mbps` 和 `io_speed_mbps` 仍可用,分别表示上下行对称带宽和读写对称 IO 限速。新接入建议使用拆分字段,以便分别控制下载/上传和读取/写入。 + +`PUT /api/v1/containers/{id}/traffic-limit` 请求体: + +```json +{ + "traffic_mode": "total", + "monthly_traffic_gb": 1024, + "traffic_in_gb": 0, + "traffic_out_gb": 0 +} +``` + +| 字段 | 说明 | +| --- | --- | +| `traffic_mode` | 流量限制模式;常用 `total` 表示总量限制,`split` 表示入站/出站分别限制。 | +| `monthly_traffic_gb` | `total` 模式下的月总流量额度,单位 GB;`0` 表示不限制。 | +| `traffic_in_gb` | `split` 模式下的月入站额度,单位 GB;`0` 表示不限制。 | +| `traffic_out_gb` | `split` 模式下的月出站额度,单位 GB;`0` 表示不限制。 | + +## 容器防火墙 + +容器防火墙通过 `GET /api/v1/containers/{id}/firewall` 读取,通过 `PUT /api/v1/containers/{id}/firewall` 更新。容器运行中更新时会立即应用规则。 + +更新示例: + +```json +{ + "enabled": true, + "default_action": "DROP", + "rules": [ + { + "direction": "in", + "protocol": "tcp", + "action": "ACCEPT", + "network": "ipv4", + "source_ip": "203.0.113.0/24", + "port": "22,80,443", + "description": "allow admin and web" + } + ] +} +``` + +| 字段 | 说明 | +| --- | --- | +| `enabled` | 是否启用容器防火墙。 | +| `default_action` | 默认动作:`ACCEPT` 或 `DROP`。 | +| `rules[].id` | 可选;新规则可省略,后端会自动生成。 | +| `rules[].direction` | 方向:`in` 或 `out`。 | +| `rules[].protocol` | 协议:`tcp`、`udp`、`icmp` 或 `all`。 | +| `rules[].action` | 动作:`ACCEPT` 或 `DROP`。 | +| `rules[].network` | 网络类型:`ipv4`、`ipv6` 或 `all`。 | +| `rules[].source_ip` | 可选;源 IP、CIDR 或地址范围。 | +| `rules[].port` | 可选;仅 `tcp`/`udp` 支持,可写 `22`、`80,443` 或 `8000-9000`。 | +| `rules[].description` | 可选备注。 | + +## API Key 创建与更新 + +`POST /api/v1/api-keys` 和 `PATCH /api/v1/api-keys/{id}` 使用相同的字段结构。创建时 `name` 必填;更新时根据需要覆盖字段。 + +```json +{ + "name": "Automation", + "ip_whitelist": "198.51.100.23,203.0.113.0/24", + "scopes": ["dashboard:read", "container:read", "container:power"], + "expires_at": "2026-12-31 23:59:59", + "disabled": false, + "container_uuids": ["00000000-0000-4000-8000-000000000005"] +} +``` + +| 字段 | 说明 | +| --- | --- | +| `name` | API Key 名称;创建时必填。 | +| `ip_whitelist` | 可选;允许的来源 IP/CIDR,多个值用逗号分隔;空值表示不限制。 | +| `scopes` | 可选;权限范围。省略时使用默认只读范围,传 `*` 表示全部权限。 | +| `expires_at` | 可选;过期时间,空值表示不过期。 | +| `disabled` | 是否禁用该 Key。 | +| `container_uuids` | 可选;限制该 Key 只能访问指定容器。 | + ## Python 示例 获取容器列表: @@ -140,6 +246,7 @@ print(resp.json()) | --- | --- | --- | | GET | `/api/v1/dashboard` | 控制面板统计 | | GET | `/api/v1/host-info` | 主机资源 | +| GET | `/api/v1/host-report` | 主机巡检报告 | | GET | `/api/v1/routing` | NAT/IPv4/IPv6 路由 | | PUT | `/api/v1/routing` | 更新公网 IPv4/IPv6 池 | | POST | `/api/v1/routing/ipv4-scan` | 扫描公网 IPv4 段 | @@ -151,10 +258,11 @@ print(resp.json()) | 方法 | 路径 | 说明 | | --- | --- | --- | -| GET | `/api/v1/containers` | 容器列表 | +| GET | `/api/v1/containers` | 容器列表(推荐) | +| GET | `/api/v1/containers/list` | 容器列表兼容 GET 写法 | | POST | `/api/v1/containers/list` | 容器列表兼容 POST 写法 | | POST | `/api/v1/containers` | 创建容器 | -| GET | `/api/v1/containers/{id|uuid|name}` | 容器详情 | +| GET | `/api/v1/containers/{id\|uuid\|name}` | 容器详情 | | POST | `/api/v1/containers/{id}/start` | 开机 | | POST | `/api/v1/containers/{id}/stop` | 关机 | | POST | `/api/v1/containers/{id}/restart` | 重启 | @@ -173,10 +281,12 @@ print(resp.json()) | 方法 | 路径 | 说明 | | --- | --- | --- | -| GET | `/api/v1/containers/{id}/random-port` | 随机可用端口 | +| GET | `/api/v1/containers/{id}/random-port` | 随机可用端口;可传 `host_ip` 查询指定宿主机 IP | | POST | `/api/v1/containers/{id}/port-mappings` | 添加端口映射 | | PUT | `/api/v1/containers/{id}/port-mappings/{index}` | 更新端口映射 | | DELETE | `/api/v1/containers/{id}/port-mappings/{index}` | 删除端口映射 | +| GET | `/api/v1/containers/{id}/firewall` | 获取容器防火墙设置 | +| PUT | `/api/v1/containers/{id}/firewall` | 更新容器防火墙设置 | | GET | `/api/v1/snapshots` | 快照总览 | | GET | `/api/v1/containers/{id}/snapshots` | 容器快照 | | POST | `/api/v1/containers/{id}/snapshots` | 创建快照 | @@ -191,6 +301,7 @@ print(resp.json()) | --- | --- | --- | | GET | `/api/v1/templates` | 模板列表 | | GET | `/api/v1/images` | 镜像管理列表 | +| GET | `/api/v1/images/enabled` | 已启用且已下载的镜像;支持 `type=lxc\|kvm` | | POST | `/api/v1/images/download` | 下载镜像 | | POST | `/api/v1/images/cancel` | 取消镜像下载 | | DELETE | `/api/v1/images/delete` | 删除镜像缓存 | @@ -203,6 +314,12 @@ print(resp.json()) | PUT | `/api/v1/security/settings` | 更新安全设置 | | GET | `/api/v1/swap` | Swap 信息 | | POST | `/api/v1/swap` | 调整 Swap | +| GET | `/api/v1/language` | 当前面板语言 | +| POST/PUT | `/api/v1/language` | 更新面板语言 | +| GET | `/api/v1/ssl` | SSL 设置(需管理员权限 / `admin:access`) | +| PUT | `/api/v1/ssl` | 更新 SSL 设置(需管理员权限 / `admin:access`) | +| GET | `/api/v1/webssh-origins` | WebSSH Origin 白名单(需管理员权限 / `admin:access`) | +| PUT | `/api/v1/webssh-origins` | 更新 WebSSH Origin 白名单(需管理员权限 / `admin:access`) | | POST | `/api/v1/batch-create` | 批量创建容器 | | POST | `/api/v1/batch-action` | 批量开关机/删除/重装 | | POST | `/api/v1/ssh-ticket` | 创建 WebSSH 票据 | @@ -255,6 +372,16 @@ print(resp.json()) "load": { "load1": 0.01, "load5": 0.03, "load15": 0.01 } } }, + "GET /api/v1/host-report": { + "success": true, + "data": { + "generated_at": "2026-06-12 10:00:00", + "summary": { "status": "ok", "warnings": 0 }, + "host": { "hostname": "node-1", "kernel": "6.8.0" }, + "resources": { "cpu_cores": 8, "ram_total_mb": 31825, "disk_total_gb": 1750.49 }, + "network": { "public_ipv4": "203.0.113.10", "public_ipv6": "2001:db8:100::2" } + } + }, "GET /api/v1/routing": { "success": true, "data": { @@ -331,6 +458,10 @@ print(resp.json()) "vcpu": 1, "ram_mb": 512, "disk_gb": 10, + "network_down_mbps": 100, + "network_up_mbps": 50, + "io_read_mbps": 120, + "io_write_mbps": 80, "status": "running", "ip": "10.0.0.10", "ipv6": "2001:db8:100::1005", @@ -343,6 +474,12 @@ print(resp.json()) } ] }, + "GET /api/v1/containers/list": { + "success": true, + "data": [ + { "id": 5, "uuid": "00000000-0000-4000-8000-000000000005", "name": "example-vm", "status": "running", "ip": "10.0.0.10" } + ] + }, "POST /api/v1/containers/list": { "success": true, "data": [ @@ -410,7 +547,7 @@ print(resp.json()) "success": true, "data": { "mode": "total", - "limit_gb": 0, + "limit_gb": 1024, "in_limit_gb": 0, "out_limit_gb": 0, "total_used_bytes": 142082, @@ -453,7 +590,7 @@ print(resp.json()) ```json { - "GET /api/v1/containers/{id}/random-port": { + "GET /api/v1/containers/{id}/random-port?host_ip=203.0.113.10": { "success": true, "data": { "port": 61320 } }, @@ -474,6 +611,21 @@ print(resp.json()) "success": true, "data": [] }, + "GET /api/v1/containers/{id}/firewall": { + "success": true, + "data": { + "enabled": true, + "default_action": "DROP", + "rules": [ + { "id": "a1b2c3d4", "direction": "in", "protocol": "tcp", "action": "ACCEPT", "network": "ipv4", "source_ip": "203.0.113.0/24", "port": "22,80,443", "description": "allow admin and web" } + ] + } + }, + "PUT /api/v1/containers/{id}/firewall": { + "success": true, + "message": "Firewall updated", + "data": { "enabled": true, "default_action": "DROP", "rules": [] } + }, "GET /api/v1/snapshots": { "success": true, "data": null @@ -539,6 +691,12 @@ print(resp.json()) { "id": "ubuntu-noble", "name": "Ubuntu 24.04", "type": "lxc", "downloaded": true, "enabled": true, "downloading": false, "progress": 0, "size_bytes": 135005452 } ] }, + "GET /api/v1/images/enabled?type=lxc": { + "success": true, + "data": [ + { "id": "ubuntu-noble", "name": "Ubuntu 24.04", "distro": "ubuntu", "release": "noble", "arch": "amd64", "variant": "default", "description": "Ubuntu 24.04 LTS", "type": "lxc" } + ] + }, "POST /api/v1/images/download": { "success": true, "message": "Already downloaded" @@ -588,6 +746,32 @@ print(resp.json()) "message": "SWAP 已调整为 16384 MB", "data": { "total_mb": 16383, "used_mb": 0, "free_mb": 16383, "enabled": true, "swap_file": "/swapfile" } }, + "GET /api/v1/language": { + "success": true, + "data": { "language": "zh" } + }, + "PUT /api/v1/language": { + "success": true, + "data": { "language": "en" } + }, + "GET /api/v1/ssl": { + "success": true, + "data": { "enabled": true, "mode": "self-signed", "target": "panel.example.com", "detected_host": "panel.example.com", "needs_restart": false } + }, + "PUT /api/v1/ssl": { + "success": true, + "message": "SSL settings saved", + "data": { "enabled": true, "mode": "self-signed", "target": "panel.example.com", "needs_restart": true } + }, + "GET /api/v1/webssh-origins": { + "success": true, + "data": { "origins": ["https://panel.example.com"], "current_origin": "https://panel.example.com" } + }, + "PUT /api/v1/webssh-origins": { + "success": true, + "message": "Origin allowlist saved", + "data": { "origins": ["https://panel.example.com"], "current_origin": "https://panel.example.com" } + }, "POST /api/v1/batch-create": { "success": true, "data": ["task-12"] @@ -654,17 +838,17 @@ print(resp.json()) "GET /api/v1/api-keys": { "success": true, "data": [ - { "id": "c271023f", "name": "Test", "prefix": "clicd_sk_dd9d...", "ip_whitelist": "", "created_at": "2026-06-08 15:44:40", "last_used": "2026-06-08 15:46:10", "scopes": ["*"], "last_used_ip": "198.51.100.23" } + { "id": "c271023f", "name": "Test", "prefix": "clicd_sk_dd9d...", "ip_whitelist": "", "created_at": "2026-06-08 15:44:40", "last_used": "2026-06-08 15:46:10", "scopes": ["*"], "expires_at": "", "disabled": false, "container_uuids": [], "last_used_ip": "198.51.100.23" } ] }, "POST /api/v1/api-keys": { "success": true, "message": "API key created. Save this key now - it won't be shown again.", - "data": { "id": "a1b2c3d4", "name": "Automation", "key": "clicd_sk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", "prefix": "clicd_sk_xxxx...", "scopes": ["dashboard:read", "container:read"] } + "data": { "id": "a1b2c3d4", "name": "Automation", "key": "clicd_sk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", "prefix": "clicd_sk_xxxx...", "ip_whitelist": "198.51.100.23", "scopes": ["dashboard:read", "container:read"], "expires_at": "2026-12-31 23:59:59", "disabled": false, "container_uuids": ["00000000-0000-4000-8000-000000000005"] } }, "PATCH /api/v1/api-keys/{id}": { "success": true, - "data": { "id": "a1b2c3d4", "name": "Automation", "prefix": "clicd_sk_xxxx...", "scopes": ["dashboard:read", "container:read"], "disabled": false } + "data": { "id": "a1b2c3d4", "name": "Automation", "prefix": "clicd_sk_xxxx...", "scopes": ["dashboard:read", "container:read"], "expires_at": "2026-12-31 23:59:59", "disabled": false, "container_uuids": ["00000000-0000-4000-8000-000000000005"] } }, "DELETE /api/v1/api-keys/{id}": { "success": true,