mirror of
https://github.com/MengMengCode/CLICD.git
synced 2026-08-05 05:36:07 +08:00
修复了一些已知问题
This commit is contained in:
@@ -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'];
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+251
-49
@@ -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)
|
||||
}
|
||||
|
||||
@@ -821,10 +821,11 @@ const requestBodySamples: Record<string, Record<string, unknown>> = {
|
||||
'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<string, unknown> = {
|
||||
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<string, unknown> = {
|
||||
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 会忽略这些重装字段。')
|
||||
|
||||
@@ -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<FirewallRule[]>([])
|
||||
const [firewallSaving, setFirewallSaving] = useState(false)
|
||||
const [firewallMessage, setFirewallMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
|
||||
const [editingFirewallRule, setEditingFirewallRule] = useState<FirewallRule | null>(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<FirewallRule['network']>; 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 管理
|
||||
</ActionButton>
|
||||
)}
|
||||
<ActionButton onClick={() => setShowFirewall(true)} disabled={isSubUserPolicyBlocked}>
|
||||
<ActionButton onClick={openFirewall} disabled={isSubUserPolicyBlocked}>
|
||||
<FirewallIcon className="w-3.5 h-3.5" />
|
||||
防火墙
|
||||
</ActionButton>
|
||||
@@ -1511,7 +1558,12 @@ export default function ContainerDetail() {
|
||||
{showFirewall && (
|
||||
<Modal title="防火墙设置" onClose={() => { setShowFirewall(false); setShowFirewallEditor(false); setEditingFirewallRule(null) }} wide extra={
|
||||
!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" />添加规则
|
||||
</button>
|
||||
)
|
||||
@@ -1521,7 +1573,11 @@ export default function ContainerDetail() {
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<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>
|
||||
<button
|
||||
onClick={() => setFirewallEnabled(!firewallEnabled)}
|
||||
@@ -1531,12 +1587,47 @@ export default function ContainerDetail() {
|
||||
</button>
|
||||
</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 */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b border-gray-200 bg-gray-50 text-xs text-gray-500">
|
||||
<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>
|
||||
@@ -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'}`} />
|
||||
</button>
|
||||
</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">
|
||||
<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' ? '入站' : '出站'}
|
||||
@@ -1571,7 +1667,14 @@ export default function ContainerDetail() {
|
||||
{!isSubUser && (
|
||||
<td className="px-3 py-2 text-right">
|
||||
<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" />
|
||||
</button>
|
||||
<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>
|
||||
))}
|
||||
{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>
|
||||
</table>
|
||||
@@ -1605,6 +1708,17 @@ export default function ContainerDetail() {
|
||||
{showFirewallEditor && editingFirewallRule && (
|
||||
<Modal title={editingFirewallRule.id ? '编辑规则' : '添加规则'} onClose={() => { setShowFirewallEditor(false); setEditingFirewallRule(null) }}>
|
||||
<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="方向">
|
||||
<select value={editingFirewallRule.direction} onChange={(e) => setEditingFirewallRule({ ...editingFirewallRule, direction: e.target.value as 'in' | 'out' })} className={inputClass}>
|
||||
<option value="in">入站 (Inbound)</option>
|
||||
@@ -1612,18 +1726,52 @@ export default function ContainerDetail() {
|
||||
</select>
|
||||
</Field>
|
||||
<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="udp">UDP</option>
|
||||
<option value="icmp">ICMP</option>
|
||||
<option value="all">全部</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="端口" hint="留空为全部端口,支持: 22 | 80,443 | 8000-9000">
|
||||
<input value={editingFirewallRule.port} onChange={(e) => setEditingFirewallRule({ ...editingFirewallRule, port: e.target.value })} placeholder="如: 22 或 80,443 或 8000-9000" className={inputClass} />
|
||||
<Field
|
||||
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 label={editingFirewallRule.direction === 'in' ? '来源 IP' : '目标 IP'} hint="留空为任意 IP,支持 CIDR: 192.168.1.0/24">
|
||||
<input value={editingFirewallRule.source_ip} onChange={(e) => setEditingFirewallRule({ ...editingFirewallRule, source_ip: e.target.value })} placeholder="如: 192.168.1.0/24" className={inputClass} />
|
||||
<Field
|
||||
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 label="动作">
|
||||
<select value={editingFirewallRule.action} onChange={(e) => setEditingFirewallRule({ ...editingFirewallRule, action: e.target.value as 'ACCEPT' | 'DROP' })} className={inputClass}>
|
||||
|
||||
@@ -725,6 +725,7 @@ function toPlaceholder(cfg: CreateContainerRequest): DisplayContainer {
|
||||
port_mappings: [],
|
||||
port_mapping_limit: cfg.assign_nat === false ? 0 : (cfg.port_mapping_count || 0),
|
||||
firewall_enabled: false,
|
||||
firewall_default_action: 'DROP',
|
||||
firewall_rules: [],
|
||||
snapshot_limit: cfg.snapshot_limit || 3,
|
||||
created_at: '',
|
||||
|
||||
@@ -47,6 +47,7 @@ export interface PortMapping {
|
||||
|
||||
export interface FirewallRule {
|
||||
id: string
|
||||
network?: 'ipv4' | 'ipv6' | 'all'
|
||||
direction: 'in' | 'out'
|
||||
protocol: 'tcp' | 'udp' | 'icmp' | 'all'
|
||||
port: string
|
||||
@@ -100,6 +101,7 @@ export interface Container {
|
||||
port_mappings: PortMapping[]
|
||||
port_mapping_limit: number
|
||||
firewall_enabled: boolean
|
||||
firewall_default_action: 'ACCEPT' | 'DROP'
|
||||
firewall_rules: FirewallRule[]
|
||||
snapshot_limit: number
|
||||
created_at: string
|
||||
@@ -501,10 +503,10 @@ export const deletePortMapping = (id: ContainerIdentifier, index: number) =>
|
||||
api.delete<APIResponse<PortMapping[]>>(`/containers/${id}/port-mappings/${index}`)
|
||||
|
||||
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[] }) =>
|
||||
api.put<APIResponse<{ enabled: boolean; rules: FirewallRule[] }>>(`/containers/${id}/firewall`, data)
|
||||
export const updateFirewall = (id: ContainerIdentifier, data: { enabled?: boolean; default_action?: 'ACCEPT' | 'DROP'; rules?: FirewallRule[] }) =>
|
||||
api.put<APIResponse<{ enabled: boolean; default_action: 'ACCEPT' | 'DROP'; rules: FirewallRule[] }>>(`/containers/${id}/firewall`, data)
|
||||
|
||||
export const updateContainerExpiry = (id: ContainerIdentifier, expiresAt: string) =>
|
||||
api.put<APIResponse>(`/containers/${id}/expiry`, { expires_at: expiresAt })
|
||||
|
||||
Reference in New Issue
Block a user