修复了一些已知问题

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();
$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'];
+113 -10
View File
@@ -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
}
+8 -6
View File
@@ -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"`
+16 -5
View File
@@ -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
+6 -1
View File
@@ -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()
}
+3
View File
@@ -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
View File
@@ -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)
}
+13 -10
View File
@@ -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 会忽略这些重装字段。')
+163 -15
View File
@@ -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' : 'IPv4NAT',
})
}
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}>
+1
View File
@@ -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: '',
+5 -3
View File
@@ -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 })