修复了一些已知问题

This commit is contained in:
MengMengCode
2026-06-12 13:18:54 +08:00
parent baf213e769
commit 4de86c458f
11 changed files with 587 additions and 99 deletions
+8
View File
@@ -1445,6 +1445,7 @@ function clicd_firewallUpdate($params)
$input = clicd_json_input(); $input = clicd_json_input();
$enabled = clicd_param_value($input, 'enabled', 'true'); $enabled = clicd_param_value($input, 'enabled', 'true');
$enabled = filter_var($enabled, FILTER_VALIDATE_BOOLEAN); $enabled = filter_var($enabled, FILTER_VALIDATE_BOOLEAN);
$defaultAction = strtoupper(trim((string)clicd_param_value($input, 'default_action', '')));
$rules = clicd_param_value($input, 'rules', '[]'); $rules = clicd_param_value($input, 'rules', '[]');
if (is_string($rules)) { if (is_string($rules)) {
@@ -1461,6 +1462,9 @@ function clicd_firewallUpdate($params)
'enabled' => $enabled, 'enabled' => $enabled,
'rules' => $rules, 'rules' => $rules,
]; ];
if (in_array($defaultAction, ['ACCEPT', 'DROP'], true)) {
$payload['default_action'] = $defaultAction;
}
$container = []; $container = [];
$containerId = clicd_container_api_id($params, $container); $containerId = clicd_container_api_id($params, $container);
@@ -1526,6 +1530,7 @@ function clicd_firewall_ajax($params)
// update // update
$enabled = clicd_param_value($input, 'enabled', 'true'); $enabled = clicd_param_value($input, 'enabled', 'true');
$enabled = filter_var($enabled, FILTER_VALIDATE_BOOLEAN); $enabled = filter_var($enabled, FILTER_VALIDATE_BOOLEAN);
$defaultAction = strtoupper(trim((string)clicd_param_value($input, 'default_action', '')));
$rules = clicd_param_value($input, 'rules', '[]'); $rules = clicd_param_value($input, 'rules', '[]');
if (is_string($rules)) { if (is_string($rules)) {
@@ -1542,6 +1547,9 @@ function clicd_firewall_ajax($params)
'enabled' => $enabled, 'enabled' => $enabled,
'rules' => $rules, '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); $call = clicd_request_debug($params, '/api/v1/containers/' . rawurlencode($containerId) . '/firewall', $payload, 'PUT', 30);
$debug[] = $call['debug']; $debug[] = $call['debug'];
+113 -10
View File
@@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"math/rand" "math/rand"
"net/http" "net/http"
"net/netip"
"strconv" "strconv"
"strings" "strings"
@@ -29,8 +30,9 @@ func getFirewall(w http.ResponseWriter, r *http.Request, id int) {
jsonResponse(w, http.StatusOK, APIResponse{ jsonResponse(w, http.StatusOK, APIResponse{
Success: true, Success: true,
Data: map[string]interface{}{ Data: map[string]interface{}{
"enabled": c.FirewallEnabled, "enabled": c.FirewallEnabled,
"rules": c.FirewallRules, "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 { var req struct {
Enabled *bool `json:"enabled"` Enabled *bool `json:"enabled"`
Rules *[]config.FirewallRule `json:"rules"` DefaultAction *string `json:"default_action"`
Rules *[]config.FirewallRule `json:"rules"`
} }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"}) jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
return return
} }
oldEnabled := c.FirewallEnabled
oldDefaultAction := c.FirewallDefaultAction
oldRules := append([]config.FirewallRule(nil), c.FirewallRules...)
if req.Enabled != nil { if req.Enabled != nil {
c.FirewallEnabled = *req.Enabled 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 { if req.Rules != nil {
// Validate and assign IDs to new rules // Validate and assign IDs to new rules
rules := *req.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].Direction = strings.ToLower(strings.TrimSpace(rules[i].Direction))
rules[i].Protocol = strings.ToLower(strings.TrimSpace(rules[i].Protocol)) rules[i].Protocol = strings.ToLower(strings.TrimSpace(rules[i].Protocol))
rules[i].Action = strings.ToUpper(strings.TrimSpace(rules[i].Action)) 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].SourceIP = strings.TrimSpace(rules[i].SourceIP)
rules[i].Port = strings.TrimSpace(rules[i].Port) 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" { if rules[i].Direction != "in" && rules[i].Direction != "out" {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid direction: " + rules[i].Direction}) jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid direction: " + rules[i].Direction})
return 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}) jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid action: " + rules[i].Action})
return 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() rules[i].ID = generateFirewallRuleID()
} }
// Validate port spec // Validate port spec
if rules[i].Port != "" { 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 { if err := validatePortSpec(rules[i].Port); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid port: " + err.Error()}) jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid port: " + err.Error()})
return return
@@ -90,11 +122,14 @@ func updateFirewall(w http.ResponseWriter, r *http.Request, id int) {
c.FirewallRules = rules c.FirewallRules = rules
} }
config.SaveConfig()
// Apply firewall rules to iptables if container is running // Apply firewall rules to iptables if container is running
if c.Status == "running" { if c.Status == "running" {
if err := lxc.ApplyFirewallRules(id); err != nil { 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()}) jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to apply firewall rules: " + err.Error()})
return return
} }
@@ -102,28 +137,54 @@ func updateFirewall(w http.ResponseWriter, r *http.Request, id int) {
// If disabled and not running, clean any lingering rules // If disabled and not running, clean any lingering rules
lxc.CleanFirewallRules(id) lxc.CleanFirewallRules(id)
} }
config.SaveConfig()
jsonResponse(w, http.StatusOK, APIResponse{ jsonResponse(w, http.StatusOK, APIResponse{
Success: true, Success: true,
Message: "Firewall updated", Message: "Firewall updated",
Data: map[string]interface{}{ Data: map[string]interface{}{
"enabled": c.FirewallEnabled, "enabled": c.FirewallEnabled,
"rules": c.FirewallRules, "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 { func validatePortSpec(port string) error {
port = strings.TrimSpace(port) port = strings.TrimSpace(port)
if port == "" { if port == "" {
return nil return nil
} }
// Support: "22", "80,443", "8000-9000", "80,443,8000-9000" // Support: "22", "80,443", "8000-9000", "80,443,8000-9000"
partCount := 0
for _, part := range strings.Split(port, ",") { for _, part := range strings.Split(port, ",") {
part = strings.TrimSpace(part) part = strings.TrimSpace(part)
if part == "" { if part == "" {
continue return &portValidationError{port}
} }
partCount++
if strings.Contains(part, "-") { if strings.Contains(part, "-") {
// Range // Range
bounds := strings.SplitN(part, "-", 2) bounds := strings.SplitN(part, "-", 2)
@@ -135,6 +196,9 @@ func validatePortSpec(port string) error {
if err != nil || hi < 1 || hi > 65535 { if err != nil || hi < 1 || hi > 65535 {
return &portValidationError{part} return &portValidationError{part}
} }
if hi < lo {
return &portValidationError{part}
}
} else { } else {
p, err := strconv.Atoi(part) p, err := strconv.Atoi(part)
if err != nil || p < 1 || p > 65535 { 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 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 { type portValidationError struct {
port string port string
} }
+8 -6
View File
@@ -24,11 +24,12 @@ type PortMapping struct {
type FirewallRule struct { type FirewallRule struct {
ID string `json:"id"` ID string `json:"id"`
Direction string `json:"direction"` // "in" or "out" Network string `json:"network,omitempty"` // "ipv4", "ipv6", or "all"; empty defaults to "ipv4"
Protocol string `json:"protocol"` // "tcp", "udp", "icmp", "all" Direction string `json:"direction"` // "in" or "out"
Port string `json:"port"` // "" = all, "22", "80,443", "8000-9000" Protocol string `json:"protocol"` // "tcp", "udp", "icmp", "all"
SourceIP string `json:"source_ip"` // "" = any Port string `json:"port"` // "" = all, "22", "80,443", "8000-9000"
Action string `json:"action"` // "ACCEPT" or "DROP" SourceIP string `json:"source_ip"` // "" = any
Action string `json:"action"` // "ACCEPT" or "DROP"
Description string `json:"description"` Description string `json:"description"`
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
} }
@@ -136,7 +137,8 @@ type Container struct {
PortMappings []PortMapping `json:"port_mappings"` PortMappings []PortMapping `json:"port_mappings"`
PortMappingLimit int `json:"port_mapping_limit"` PortMappingLimit int `json:"port_mapping_limit"`
FirewallEnabled bool `json:"firewall_enabled"` 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"` SnapshotLimit int `json:"snapshot_limit"`
CreatedAt string `json:"created_at"` CreatedAt string `json:"created_at"`
ExpiresAt string `json:"expires_at"` ExpiresAt string `json:"expires_at"`
+16 -5
View File
@@ -365,6 +365,7 @@ func ensureSchemaMigrations() error {
{"container_public_ipv4s", "prefix_len", "INTEGER"}, {"container_public_ipv4s", "prefix_len", "INTEGER"},
{"container_public_ipv4s", "gateway", "TEXT"}, {"container_public_ipv4s", "gateway", "TEXT"},
{"containers", "firewall_enabled", "INTEGER NOT NULL DEFAULT 0"}, {"containers", "firewall_enabled", "INTEGER NOT NULL DEFAULT 0"},
{"containers", "firewall_default_action", "TEXT NOT NULL DEFAULT 'DROP'"},
{"containers", "firewall_rules", "TEXT"}, {"containers", "firewall_rules", "TEXT"},
} { } {
if err := ensureColumn(column.table, column.name, column.def); err != nil { 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_enabled, snapshot_schedule_interval_hours, snapshot_schedule_time,
snapshot_schedule_last_run, snapshot_schedule_next_run, snapshot_schedule_created_by, 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 firewall_enabled, firewall_default_action, firewall_rules
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
c.ID, c.UUID, c.Name, c.Virtualization, c.LXCName, c.KVMName, c.DiskImage, c.MACAddress, c.Template, 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.VCPU, c.RAMMB, c.DiskGB, c.NetworkBWMbps, c.MonthlyTrafficGB, c.TrafficMode, c.TrafficInGB,
c.TrafficOutGB, c.TrafficUsedRX, c.TrafficUsedTX, c.TrafficResetDate, c.IOSpeedMBps, 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, boolInt(c.SnapshotScheduleEnabled), c.SnapshotScheduleIntervalHours, c.SnapshotScheduleTime,
c.SnapshotScheduleLastRun, c.SnapshotScheduleNextRun, c.SnapshotScheduleCreatedBy, c.SnapshotScheduleLastRun, c.SnapshotScheduleNextRun, c.SnapshotScheduleCreatedBy,
boolInt(c.PolicyBlocked), c.PolicyBlockedReason, c.PolicyBlockedAt, boolInt(c.PolicyBlocked), c.PolicyBlockedReason, c.PolicyBlockedAt,
boolInt(c.FirewallEnabled), marshalFirewallRules(c.FirewallRules), boolInt(c.FirewallEnabled), normalizeFirewallDefaultAction(c.FirewallDefaultAction), marshalFirewallRules(c.FirewallRules),
); err != nil { ); err != nil {
return err return err
} }
@@ -794,7 +795,7 @@ func loadContainers() ([]Container, error) {
snapshot_schedule_enabled, snapshot_schedule_interval_hours, snapshot_schedule_time, snapshot_schedule_enabled, snapshot_schedule_interval_hours, snapshot_schedule_time,
snapshot_schedule_last_run, snapshot_schedule_next_run, snapshot_schedule_created_by, 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 firewall_enabled, firewall_default_action, firewall_rules
FROM containers ORDER BY id`) FROM containers ORDER BY id`)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -805,6 +806,7 @@ func loadContainers() ([]Container, error) {
for rows.Next() { for rows.Next() {
var c Container var c Container
var scheduleEnabled, policyBlocked, firewallEnabled int var scheduleEnabled, policyBlocked, firewallEnabled int
var firewallDefaultAction string
var firewallRulesJSON sql.NullString var firewallRulesJSON sql.NullString
if err := rows.Scan( if err := rows.Scan(
&c.ID, &c.UUID, &c.Name, &c.Virtualization, &c.LXCName, &c.KVMName, &c.DiskImage, &c.MACAddress, &c.Template, &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, &scheduleEnabled, &c.SnapshotScheduleIntervalHours, &c.SnapshotScheduleTime,
&c.SnapshotScheduleLastRun, &c.SnapshotScheduleNextRun, &c.SnapshotScheduleCreatedBy, &c.SnapshotScheduleLastRun, &c.SnapshotScheduleNextRun, &c.SnapshotScheduleCreatedBy,
&policyBlocked, &c.PolicyBlockedReason, &c.PolicyBlockedAt, &policyBlocked, &c.PolicyBlockedReason, &c.PolicyBlockedAt,
&firewallEnabled, &firewallRulesJSON, &firewallEnabled, &firewallDefaultAction, &firewallRulesJSON,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
c.SnapshotScheduleEnabled = scheduleEnabled != 0 c.SnapshotScheduleEnabled = scheduleEnabled != 0
c.PolicyBlocked = policyBlocked != 0 c.PolicyBlocked = policyBlocked != 0
c.FirewallEnabled = firewallEnabled != 0 c.FirewallEnabled = firewallEnabled != 0
c.FirewallDefaultAction = normalizeFirewallDefaultAction(firewallDefaultAction)
if firewallRulesJSON.Valid && strings.TrimSpace(firewallRulesJSON.String) != "" { if firewallRulesJSON.Valid && strings.TrimSpace(firewallRulesJSON.String) != "" {
_ = json.Unmarshal([]byte(firewallRulesJSON.String), &c.FirewallRules) _ = json.Unmarshal([]byte(firewallRulesJSON.String), &c.FirewallRules)
} }
@@ -1189,6 +1192,14 @@ func marshalFirewallRules(rules []FirewallRule) interface{} {
return string(data) 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{} { func boolPtrInt(value *bool) interface{} {
if value == nil { if value == nil {
return nil return nil
+6 -1
View File
@@ -3231,6 +3231,11 @@ func (m *Manager) applyIPv6Runtime(c *config.Container) error {
} }
ensureKVMIPv6NAT66(assignment.Address, uplink) 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 return nil
} }
@@ -3307,7 +3312,7 @@ func ensureKVMIPv6ForwardRules(ipv6 string, bridge string) {
} }
for _, rule := range rules { for _, rule := range rules {
check := append([]string{"-C"}, rule...) 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 { if exec.Command("ip6tables", check...).Run() != nil {
exec.Command("ip6tables", add...).Run() exec.Command("ip6tables", add...).Run()
} }
+3
View File
@@ -1610,6 +1610,9 @@ func (m *Manager) ApplyIPv6(id int) error {
ensureIPv6NAT66(assignment.Address, uplink) 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 return nil
} }
+251 -49
View File
@@ -67,6 +67,10 @@ func (m *Manager) ApplyPortMappings(id int) error {
applyIPv4EgressPolicy(c, bridge, subnet, tag) applyIPv4EgressPolicy(c, bridge, subnet, tag)
if err := ApplyFirewallRules(id); err != nil {
return err
}
return nil return nil
} }
@@ -260,8 +264,8 @@ func EnsureForwardRules(bridge string) {
break break
} }
} }
insertArgs := append([]string{"-I", "FORWARD", "1"}, args...) appendArgs := append([]string{"-A", "FORWARD"}, args...)
exec.Command("iptables", insertArgs...).Run() exec.Command("iptables", appendArgs...).Run()
} }
} }
@@ -576,6 +580,9 @@ func CleanFirewallRules(id int) {
cmd := exec.Command("bash", "-c", 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)) 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.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) // Also remove legacy default policy rules (without specific rule ID)
for _, suffix := range []string{"default-in", "default-out"} { 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), "-m", "comment", "--comment", fmt.Sprintf("clicd-%s-fw-%s-%s", tag, suffix, proto),
).CombinedOutput() ).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() { if c.IsKVM() {
bridge = "virbr0" bridge = "virbr0"
} }
containerIP := c.IP containerIP := strings.TrimSpace(c.IP)
if containerIP == "" { containerIPv6s := firewallIPv6Addresses(c)
if containerIP == "" && len(containerIPv6s) == 0 {
return nil return nil
} }
tag := clicdTag(id) tag := clicdTag(id)
// Apply default DROP policy first (inserted at position 1). defaultAction := normalizeFirewallDefaultAction(c.FirewallDefaultAction)
// Then insert ACCEPT rules (also at position 1), which pushes the DROPs down. if defaultAction == "DROP" {
// Final order: ACCEPT rules on top, DROP defaults below, bridge ACCEPT rules at the bottom. if containerIP != "" {
applyDefaultFirewallPolicy(tag, bridge, 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 { if !rule.Enabled {
continue continue
} }
if err := applyOneFirewallRule(tag, bridge, containerIP, rule); err != nil { if containerIP != "" && firewallRuleAppliesToFamily(rule, true) {
fmt.Printf("Warning: failed to apply firewall rule %s for container %d: %v\n", rule.ID, id, err) 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 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 { func applyOneFirewallRule(tag, bridge, containerIP string, rule config.FirewallRule) error {
commentTag := fmt.Sprintf("clicd-%s-fw-%s", tag, rule.ID) commentTag := fmt.Sprintf("clicd-%s-fw-%s", tag, rule.ID)
// Build base iptables args // Build base iptables args
args := []string{"-I", "FORWARD", "1"} args := []string{"-I", "FORWARD", "1"}
// Direction: in = traffic arriving at container (-i bridge -d containerIP) // Direction: in = traffic arriving at container (-o bridge -d containerIP)
// out = traffic leaving container (-o bridge -s containerIP) // out = traffic leaving container (-i bridge -s containerIP)
switch rule.Direction { switch rule.Direction {
case "in": case "in":
args = append(args, "-i", bridge, "-d", containerIP+"/32") args = append(args, "-o", bridge, "-d", containerIP+"/32")
case "out": case "out":
args = append(args, "-o", bridge, "-s", containerIP+"/32") args = append(args, "-i", bridge, "-s", containerIP+"/32")
default: default:
return fmt.Errorf("invalid direction: %s", rule.Direction) 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") { if rule.Port != "" && (rule.Protocol == "tcp" || rule.Protocol == "udp") {
// For "in" direction, traffic going TO the container uses --dport // For "in" direction, traffic going TO the container uses --dport
// For "out" direction, traffic going FROM the container uses --dport (destination port on remote) // 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) // 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 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. // 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 { func normalizePortSpec(port string) string {
port = strings.TrimSpace(port) port = strings.TrimSpace(port)
if port == "" { if port == "" {
return "" return ""
} }
// Convert comma-separated to iptables format (already valid) parts := strings.Split(port, ",")
// Convert dash range to colon range: "8000-9000" -> "8000:9000" for i, part := range parts {
if strings.Contains(port, "-") && !strings.Contains(port, ":") { part = strings.TrimSpace(part)
parts := strings.SplitN(port, "-", 2) if strings.Contains(part, "-") && !strings.Contains(part, ":") {
if len(parts) == 2 { bounds := strings.SplitN(part, "-", 2)
return strings.TrimSpace(parts[0]) + ":" + strings.TrimSpace(parts[1]) 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) { func applyDefaultFirewallPolicy(tag, bridge, containerIP string) error {
// Default DROP: inserted at position 1 so they sit above bridge ACCEPT rules. defaults := [][]string{
// 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", "-I", "FORWARD", "1",
"-o", bridge, "-o", bridge,
"-s", containerIP + "/32", "-d", containerIP + "/32",
"-p", proto,
"-j", "DROP", "-j", "DROP",
"-m", "comment", "--comment", fmt.Sprintf("clicd-%s-fw-default-out-%s", tag, proto), "-m", "comment", "--comment", fmt.Sprintf("clicd-%s-fw-default-in", tag),
} },
cmd := exec.Command("iptables", args...) {
cmd.CombinedOutput() "-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)
} }
+13 -10
View File
@@ -821,10 +821,11 @@ const requestBodySamples: Record<string, Record<string, unknown>> = {
'POST /api/v1/security/check': { container_name: 'example-vm' }, 'POST /api/v1/security/check': { container_name: 'example-vm' },
'PUT /api/v1/containers/{id}/firewall': { 'PUT /api/v1/containers/{id}/firewall': {
enabled: true, enabled: true,
default_action: 'DROP',
rules: [ rules: [
{ id: '', direction: 'in', protocol: 'tcp', port: '22', source_ip: '', action: 'ACCEPT', description: 'Allow SSH', enabled: true }, { id: '', network: 'ipv4', direction: 'in', protocol: 'tcp', port: '22', source_ip: '', action: 'ACCEPT', description: 'Allow SSH over IPv4/NAT4', enabled: true },
{ id: '', direction: 'in', protocol: 'tcp', port: '80,443', source_ip: '', action: 'ACCEPT', description: 'Allow HTTP/HTTPS', enabled: true }, { id: '', network: 'ipv6', direction: 'in', protocol: 'tcp', port: '22', source_ip: '', action: 'ACCEPT', description: 'Allow SSH over IPv6', enabled: true },
{ id: '', direction: 'out', protocol: 'tcp', port: '', source_ip: '', action: 'ACCEPT', description: 'Allow all outbound TCP', 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 }, 'PUT /api/v1/security/settings': { auto_shutdown: false },
@@ -1039,10 +1040,11 @@ const responseSamples: Record<string, unknown> = {
success: true, success: true,
data: { data: {
enabled: true, enabled: true,
default_action: 'DROP',
rules: [ rules: [
{ id: 'a1b2c3d4', direction: 'in', protocol: 'tcp', port: '22', source_ip: '', action: 'ACCEPT', description: 'Allow SSH', 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', direction: 'in', protocol: 'tcp', port: '80,443', source_ip: '', action: 'ACCEPT', description: 'Allow HTTP/HTTPS', 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', direction: 'out', protocol: 'tcp', port: '', source_ip: '', action: 'ACCEPT', description: 'Allow all outbound TCP', 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<string, unknown> = {
message: 'Firewall updated', message: 'Firewall updated',
data: { data: {
enabled: true, enabled: true,
default_action: 'DROP',
rules: [ rules: [
{ id: 'a1b2c3d4', direction: 'in', protocol: 'tcp', port: '22', source_ip: '', action: 'ACCEPT', description: 'Allow SSH', 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', direction: 'in', protocol: 'tcp', port: '80,443', source_ip: '', action: 'ACCEPT', description: 'Allow HTTP/HTTPS', 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', direction: 'out', protocol: 'tcp', port: '', source_ip: '', action: 'ACCEPT', description: 'Allow all outbound TCP', 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 认证字段。') notes.push('批量创建的单个 containers[] 项支持与 POST /api/v1/containers 相同的网络和 SSH 认证字段。')
} }
if (key === 'PUT /api/v1/containers/{id}/firewall') { 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') { if (key === 'POST /api/v1/batch-action') {
notes.push('action=reinstall 时可追加 template_id、ssh_auth_mode、ssh_password、ssh_public_key;其他 action 会忽略这些重装字段。') notes.push('action=reinstall 时可追加 template_id、ssh_auth_mode、ssh_password、ssh_public_key;其他 action 会忽略这些重装字段。')
+163 -15
View File
@@ -46,6 +46,7 @@ import {
HostInfo, HostInfo,
TrafficInfo, TrafficInfo,
getEnabledImages, getEnabledImages,
getFirewall,
PortMapping, PortMapping,
FirewallRule, FirewallRule,
reinstallContainer, reinstallContainer,
@@ -166,8 +167,10 @@ export default function ContainerDetail() {
const [snapshotScheduleDraft, setSnapshotScheduleDraft] = useState({ intervalHours: 24, time: '03:00' }) const [snapshotScheduleDraft, setSnapshotScheduleDraft] = useState({ intervalHours: 24, time: '03:00' })
const [showFirewall, setShowFirewall] = useState(false) const [showFirewall, setShowFirewall] = useState(false)
const [firewallEnabled, setFirewallEnabled] = useState(false) const [firewallEnabled, setFirewallEnabled] = useState(false)
const [firewallDefaultAction, setFirewallDefaultAction] = useState<'ACCEPT' | 'DROP'>('DROP')
const [firewallRules, setFirewallRules] = useState<FirewallRule[]>([]) const [firewallRules, setFirewallRules] = useState<FirewallRule[]>([])
const [firewallSaving, setFirewallSaving] = useState(false) const [firewallSaving, setFirewallSaving] = useState(false)
const [firewallMessage, setFirewallMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
const [editingFirewallRule, setEditingFirewallRule] = useState<FirewallRule | null>(null) const [editingFirewallRule, setEditingFirewallRule] = useState<FirewallRule | null>(null)
const [showFirewallEditor, setShowFirewallEditor] = useState(false) 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 if (!container) return
setFirewallEnabled(container.firewall_enabled || false) syncFirewallState(container.firewall_enabled || false, container.firewall_default_action || 'DROP', container.firewall_rules || [])
setFirewallRules(container.firewall_rules ? [...container.firewall_rules.map(r => ({ ...r }))] : []) setFirewallMessage(null)
setShowFirewall(true) 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 () => { const saveFirewall = async () => {
if (!container) return if (!container) return
setFirewallSaving(true) setFirewallSaving(true)
try { 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() fetchContainer()
} catch (err: any) { } catch (err: any) {
dialog.alert('错误', err?.response?.data?.message || '保存防火墙设置失败') const message = err?.response?.data?.message || '保存防火墙设置失败'
setFirewallMessage({ type: 'error', text: message })
dialog.alert('错误', message)
} finally { } finally {
setFirewallSaving(false) setFirewallSaving(false)
} }
} }
const addFirewallRule = () => { 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({ setEditingFirewallRule({
id: '', id: '',
network: hasIPv4Firewall ? 'ipv4' : hasIPv6Firewall ? 'ipv6' : 'ipv4',
direction: 'in', direction: 'in',
protocol: 'tcp', protocol: 'tcp',
port: '', port: '',
@@ -876,6 +909,20 @@ export default function ContainerDetail() {
const mappingLimit = Math.max(container.port_mapping_limit || 0, mappingCount) const mappingLimit = Math.max(container.port_mapping_limit || 0, mappingCount)
const hasNATQuota = mappingLimit > 0 const hasNATQuota = mappingLimit > 0
const canAddMapping = hasNATQuota && mappingCount < mappingLimit && !isSubUserPolicyBlocked const canAddMapping = hasNATQuota && mappingCount < mappingLimit && !isSubUserPolicyBlocked
const hasFirewallIPv4 = hasIndependentIPv4 || hasNATQuota
const firewallNetworkOptions: Array<{ value: NonNullable<FirewallRule['network']>; label: string }> = []
if (hasFirewallIPv4) {
firewallNetworkOptions.push({
value: 'ipv4',
label: hasIndependentIPv4 ? 'IPv4(公网 IPv4' : 'IPv4NAT',
})
}
if (hasIndependentIPv6) {
firewallNetworkOptions.push({ value: 'ipv6', label: 'IPv6' })
}
if (hasFirewallIPv4 && hasIndependentIPv6) {
firewallNetworkOptions.push({ value: 'all', label: '全部网络' })
}
const managementUrl = subUser?.access_code const managementUrl = subUser?.access_code
? `${window.location.origin}/login?code=${encodeURIComponent(subUser.access_code)}` ? `${window.location.origin}/login?code=${encodeURIComponent(subUser.access_code)}`
: '' : ''
@@ -997,7 +1044,7 @@ export default function ContainerDetail() {
IPv4 NAT IPv4 NAT
</ActionButton> </ActionButton>
)} )}
<ActionButton onClick={() => setShowFirewall(true)} disabled={isSubUserPolicyBlocked}> <ActionButton onClick={openFirewall} disabled={isSubUserPolicyBlocked}>
<FirewallIcon className="w-3.5 h-3.5" /> <FirewallIcon className="w-3.5 h-3.5" />
</ActionButton> </ActionButton>
@@ -1511,7 +1558,12 @@ export default function ContainerDetail() {
{showFirewall && ( {showFirewall && (
<Modal title="防火墙设置" onClose={() => { setShowFirewall(false); setShowFirewallEditor(false); setEditingFirewallRule(null) }} wide extra={ <Modal title="防火墙设置" onClose={() => { setShowFirewall(false); setShowFirewallEditor(false); setEditingFirewallRule(null) }} wide extra={
!isSubUser && ( !isSubUser && (
<button onClick={addFirewallRule} className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-black text-white rounded-md text-xs hover:bg-gray-800"> <button
onClick={addFirewallRule}
disabled={firewallNetworkOptions.length === 0}
title={firewallNetworkOptions.length === 0 ? '当前容器没有可配置的 NAT、公网 IPv4 或 IPv6' : undefined}
className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-black text-white rounded-md text-xs hover:bg-gray-800 disabled:cursor-not-allowed disabled:opacity-50"
>
<Plus className="w-3.5 h-3.5" /> <Plus className="w-3.5 h-3.5" />
</button> </button>
) )
@@ -1521,7 +1573,11 @@ export default function ContainerDetail() {
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
<div> <div>
<div className="text-sm font-medium text-gray-800"></div> <div className="text-sm font-medium text-gray-800"></div>
<div className="text-xs text-gray-500"></div> <div className="text-xs text-gray-500">
{firewallEnabled
? (firewallDefaultAction === 'DROP' ? '已启用,未匹配规则的流量将被拒绝' : '已启用,未匹配规则的流量将被放行')
: '未启用时不接管该容器流量'}
</div>
</div> </div>
<button <button
onClick={() => setFirewallEnabled(!firewallEnabled)} onClick={() => setFirewallEnabled(!firewallEnabled)}
@@ -1531,12 +1587,47 @@ export default function ContainerDetail() {
</button> </button>
</div> </div>
<div className="flex items-center justify-between gap-4 rounded-md border border-gray-200 px-3 py-2">
<div>
<div className="text-sm font-medium text-gray-800"></div>
<div className="text-xs text-gray-500"></div>
</div>
<select
value={firewallDefaultAction}
onChange={(e) => setFirewallDefaultAction(e.target.value as 'ACCEPT' | 'DROP')}
disabled={isSubUser}
className="rounded-md border border-gray-300 bg-white px-2.5 py-1.5 text-xs text-gray-800 focus:border-black focus:outline-none focus:ring-2 focus:ring-black disabled:opacity-60"
>
<option value="DROP"></option>
<option value="ACCEPT"></option>
</select>
</div>
<div className="rounded-md border border-blue-100 bg-blue-50 px-3 py-2 text-xs text-blue-800">
<div className="font-medium text-blue-900"></div>
<div className="mt-1">
{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 地址。' : ''}
</div>
</div>
{firewallMessage && (
<div className={`rounded-md px-3 py-2 text-xs ${firewallMessage.type === 'success' ? 'border border-emerald-100 bg-emerald-50 text-emerald-700' : 'border border-red-100 bg-red-50 text-red-700'}`}>
{firewallMessage.text}
</div>
)}
{/* Rules table */} {/* Rules table */}
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead className="border-b border-gray-200 bg-gray-50 text-xs text-gray-500"> <thead className="border-b border-gray-200 bg-gray-50 text-xs text-gray-500">
<tr> <tr>
<th className="px-3 py-2 text-left font-medium"></th> <th className="px-3 py-2 text-left font-medium"></th>
<th className="px-3 py-2 text-left font-medium"></th>
<th className="px-3 py-2 text-left font-medium"></th> <th className="px-3 py-2 text-left font-medium"></th>
<th className="px-3 py-2 text-left font-medium"></th> <th className="px-3 py-2 text-left font-medium"></th>
<th className="px-3 py-2 text-left font-medium"></th> <th className="px-3 py-2 text-left font-medium"></th>
@@ -1554,6 +1645,11 @@ export default function ContainerDetail() {
<span className={`inline-block h-3 w-3 transform rounded-full bg-white transition-transform ${rule.enabled ? 'translate-x-3.5' : 'translate-x-0.5'}`} /> <span className={`inline-block h-3 w-3 transform rounded-full bg-white transition-transform ${rule.enabled ? 'translate-x-3.5' : 'translate-x-0.5'}`} />
</button> </button>
</td> </td>
<td className="px-3 py-2">
<span className="inline-flex rounded bg-gray-100 px-1.5 py-0.5 text-xs font-medium text-gray-700">
{(rule.network || 'ipv4') === 'ipv6' ? 'IPv6' : (rule.network || 'ipv4') === 'all' ? '全部' : 'IPv4'}
</span>
</td>
<td className="px-3 py-2"> <td className="px-3 py-2">
<span className={`inline-flex px-1.5 py-0.5 rounded text-xs font-medium ${rule.direction === 'in' ? 'bg-blue-50 text-blue-700' : 'bg-orange-50 text-orange-700'}`}> <span className={`inline-flex px-1.5 py-0.5 rounded text-xs font-medium ${rule.direction === 'in' ? 'bg-blue-50 text-blue-700' : 'bg-orange-50 text-orange-700'}`}>
{rule.direction === 'in' ? '入站' : '出站'} {rule.direction === 'in' ? '入站' : '出站'}
@@ -1571,7 +1667,14 @@ export default function ContainerDetail() {
{!isSubUser && ( {!isSubUser && (
<td className="px-3 py-2 text-right"> <td className="px-3 py-2 text-right">
<div className="inline-flex items-center gap-1"> <div className="inline-flex items-center gap-1">
<button onClick={() => { setEditingFirewallRule({ ...rule }); setShowFirewallEditor(true) }} className="p-1.5 text-gray-400 hover:text-gray-700 rounded hover:bg-gray-100"> <button onClick={() => {
const currentNetwork = (rule.network || 'ipv4') as NonNullable<FirewallRule['network']>
const network = firewallNetworkOptions.some((option) => option.value === currentNetwork)
? currentNetwork
: (firewallNetworkOptions[0]?.value || currentNetwork)
setEditingFirewallRule({ ...rule, network })
setShowFirewallEditor(true)
}} className="p-1.5 text-gray-400 hover:text-gray-700 rounded hover:bg-gray-100">
<Pencil className="h-3.5 w-3.5" /> <Pencil className="h-3.5 w-3.5" />
</button> </button>
<button onClick={() => deleteFirewallRule(rule.id)} className="p-1.5 text-gray-400 hover:text-red-600 rounded hover:bg-red-50"> <button onClick={() => deleteFirewallRule(rule.id)} className="p-1.5 text-gray-400 hover:text-red-600 rounded hover:bg-red-50">
@@ -1583,7 +1686,7 @@ export default function ContainerDetail() {
</tr> </tr>
))} ))}
{firewallRules.length === 0 && ( {firewallRules.length === 0 && (
<tr><td colSpan={isSubUser ? 7 : 8} className="px-3 py-6 text-center text-xs text-gray-400"></td></tr> <tr><td colSpan={isSubUser ? 8 : 9} className="px-3 py-6 text-center text-xs text-gray-400"></td></tr>
)} )}
</tbody> </tbody>
</table> </table>
@@ -1605,6 +1708,17 @@ export default function ContainerDetail() {
{showFirewallEditor && editingFirewallRule && ( {showFirewallEditor && editingFirewallRule && (
<Modal title={editingFirewallRule.id ? '编辑规则' : '添加规则'} onClose={() => { setShowFirewallEditor(false); setEditingFirewallRule(null) }}> <Modal title={editingFirewallRule.id ? '编辑规则' : '添加规则'} onClose={() => { setShowFirewallEditor(false); setEditingFirewallRule(null) }}>
<div className="space-y-4"> <div className="space-y-4">
<Field label="网络">
{firewallNetworkOptions.length > 0 ? (
<select value={editingFirewallRule.network || firewallNetworkOptions[0].value} onChange={(e) => setEditingFirewallRule({ ...editingFirewallRule, network: e.target.value as FirewallRule['network'] })} className={inputClass}>
{firewallNetworkOptions.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
) : (
<input value="当前容器没有可配置网络" disabled className={`${inputClass} bg-gray-100 text-gray-400`} />
)}
</Field>
<Field label="方向"> <Field label="方向">
<select value={editingFirewallRule.direction} onChange={(e) => setEditingFirewallRule({ ...editingFirewallRule, direction: e.target.value as 'in' | 'out' })} className={inputClass}> <select value={editingFirewallRule.direction} onChange={(e) => setEditingFirewallRule({ ...editingFirewallRule, direction: e.target.value as 'in' | 'out' })} className={inputClass}>
<option value="in"> (Inbound)</option> <option value="in"> (Inbound)</option>
@@ -1612,18 +1726,52 @@ export default function ContainerDetail() {
</select> </select>
</Field> </Field>
<Field label="协议"> <Field label="协议">
<select value={editingFirewallRule.protocol} onChange={(e) => setEditingFirewallRule({ ...editingFirewallRule, protocol: e.target.value as any })} className={inputClass}> <select
value={editingFirewallRule.protocol}
onChange={(e) => {
const protocol = e.target.value as FirewallRule['protocol']
setEditingFirewallRule({
...editingFirewallRule,
protocol,
port: protocol === 'tcp' || protocol === 'udp' ? editingFirewallRule.port : '',
})
}}
className={inputClass}
>
<option value="tcp">TCP</option> <option value="tcp">TCP</option>
<option value="udp">UDP</option> <option value="udp">UDP</option>
<option value="icmp">ICMP</option> <option value="icmp">ICMP</option>
<option value="all"></option> <option value="all"></option>
</select> </select>
</Field> </Field>
<Field label="端口" hint="留空为全部端口,支持: 22 | 80,443 | 8000-9000"> <Field
<input value={editingFirewallRule.port} onChange={(e) => setEditingFirewallRule({ ...editingFirewallRule, port: e.target.value })} placeholder="如: 22 或 80,443 或 8000-9000" className={inputClass} /> label="端口"
hint={editingFirewallRule.protocol === 'tcp' || editingFirewallRule.protocol === 'udp'
? (editingFirewallRule.direction === 'in'
? ((editingFirewallRule.network || 'ipv4') === 'ipv4' && hasNATQuota && !hasIndependentIPv4
? 'NAT 入站填容器内部端口,例如公网 22023 -> 容器 22,这里填 22'
: '入站填容器服务端口;留空为全部端口,支持: 22 | 80,443 | 8000-9000')
: '出站填远端目标端口;留空为全部端口,支持: 22 | 80,443 | 8000-9000')
: '端口仅适用于 TCP/UDP'}
>
<input
value={editingFirewallRule.port}
onChange={(e) => 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`}
/>
</Field> </Field>
<Field label={editingFirewallRule.direction === 'in' ? '来源 IP' : '目标 IP'} hint="留空为任意 IP,支持 CIDR: 192.168.1.0/24"> <Field
<input value={editingFirewallRule.source_ip} onChange={(e) => setEditingFirewallRule({ ...editingFirewallRule, source_ip: e.target.value })} placeholder="如: 192.168.1.0/24" className={inputClass} /> label={editingFirewallRule.direction === 'in' ? '来源 IP' : '目标 IP'}
hint={(editingFirewallRule.network || 'ipv4') === 'ipv6' ? '留空为任意 IPv6,支持 CIDR: 2001:db8::/64' : (editingFirewallRule.network || 'ipv4') === 'all' ? '留空为任意 IP,支持 IPv4/IPv6 CIDR' : '留空为任意 IPv4,支持 CIDR: 192.168.1.0/24'}
>
<input
value={editingFirewallRule.source_ip}
onChange={(e) => 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}
/>
</Field> </Field>
<Field label="动作"> <Field label="动作">
<select value={editingFirewallRule.action} onChange={(e) => setEditingFirewallRule({ ...editingFirewallRule, action: e.target.value as 'ACCEPT' | 'DROP' })} className={inputClass}> <select value={editingFirewallRule.action} onChange={(e) => setEditingFirewallRule({ ...editingFirewallRule, action: e.target.value as 'ACCEPT' | 'DROP' })} className={inputClass}>
+1
View File
@@ -725,6 +725,7 @@ function toPlaceholder(cfg: CreateContainerRequest): DisplayContainer {
port_mappings: [], port_mappings: [],
port_mapping_limit: cfg.assign_nat === false ? 0 : (cfg.port_mapping_count || 0), port_mapping_limit: cfg.assign_nat === false ? 0 : (cfg.port_mapping_count || 0),
firewall_enabled: false, firewall_enabled: false,
firewall_default_action: 'DROP',
firewall_rules: [], firewall_rules: [],
snapshot_limit: cfg.snapshot_limit || 3, snapshot_limit: cfg.snapshot_limit || 3,
created_at: '', created_at: '',
+5 -3
View File
@@ -47,6 +47,7 @@ export interface PortMapping {
export interface FirewallRule { export interface FirewallRule {
id: string id: string
network?: 'ipv4' | 'ipv6' | 'all'
direction: 'in' | 'out' direction: 'in' | 'out'
protocol: 'tcp' | 'udp' | 'icmp' | 'all' protocol: 'tcp' | 'udp' | 'icmp' | 'all'
port: string port: string
@@ -100,6 +101,7 @@ export interface Container {
port_mappings: PortMapping[] port_mappings: PortMapping[]
port_mapping_limit: number port_mapping_limit: number
firewall_enabled: boolean firewall_enabled: boolean
firewall_default_action: 'ACCEPT' | 'DROP'
firewall_rules: FirewallRule[] firewall_rules: FirewallRule[]
snapshot_limit: number snapshot_limit: number
created_at: string created_at: string
@@ -501,10 +503,10 @@ export const deletePortMapping = (id: ContainerIdentifier, index: number) =>
api.delete<APIResponse<PortMapping[]>>(`/containers/${id}/port-mappings/${index}`) api.delete<APIResponse<PortMapping[]>>(`/containers/${id}/port-mappings/${index}`)
export const getFirewall = (id: ContainerIdentifier) => export const getFirewall = (id: ContainerIdentifier) =>
api.get<APIResponse<{ enabled: boolean; rules: FirewallRule[] }>>(`/containers/${id}/firewall`) api.get<APIResponse<{ enabled: boolean; default_action: 'ACCEPT' | 'DROP'; rules: FirewallRule[] }>>(`/containers/${id}/firewall`)
export const updateFirewall = (id: ContainerIdentifier, data: { enabled?: boolean; rules?: FirewallRule[] }) => export const updateFirewall = (id: ContainerIdentifier, data: { enabled?: boolean; default_action?: 'ACCEPT' | 'DROP'; rules?: FirewallRule[] }) =>
api.put<APIResponse<{ enabled: boolean; rules: FirewallRule[] }>>(`/containers/${id}/firewall`, data) api.put<APIResponse<{ enabled: boolean; default_action: 'ACCEPT' | 'DROP'; rules: FirewallRule[] }>>(`/containers/${id}/firewall`, data)
export const updateContainerExpiry = (id: ContainerIdentifier, expiresAt: string) => export const updateContainerExpiry = (id: ContainerIdentifier, expiresAt: string) =>
api.put<APIResponse>(`/containers/${id}/expiry`, { expires_at: expiresAt }) api.put<APIResponse>(`/containers/${id}/expiry`, { expires_at: expiresAt })