mirror of
https://github.com/MengMengCode/CLICD.git
synced 2026-08-06 05:52:19 +08:00
修复了一些已知问题
This commit is contained in:
@@ -1445,6 +1445,7 @@ function clicd_firewallUpdate($params)
|
|||||||
$input = clicd_json_input();
|
$input = clicd_json_input();
|
||||||
$enabled = clicd_param_value($input, 'enabled', 'true');
|
$enabled = clicd_param_value($input, 'enabled', 'true');
|
||||||
$enabled = filter_var($enabled, FILTER_VALIDATE_BOOLEAN);
|
$enabled = filter_var($enabled, FILTER_VALIDATE_BOOLEAN);
|
||||||
|
$defaultAction = strtoupper(trim((string)clicd_param_value($input, 'default_action', '')));
|
||||||
$rules = clicd_param_value($input, 'rules', '[]');
|
$rules = clicd_param_value($input, 'rules', '[]');
|
||||||
|
|
||||||
if (is_string($rules)) {
|
if (is_string($rules)) {
|
||||||
@@ -1461,6 +1462,9 @@ function clicd_firewallUpdate($params)
|
|||||||
'enabled' => $enabled,
|
'enabled' => $enabled,
|
||||||
'rules' => $rules,
|
'rules' => $rules,
|
||||||
];
|
];
|
||||||
|
if (in_array($defaultAction, ['ACCEPT', 'DROP'], true)) {
|
||||||
|
$payload['default_action'] = $defaultAction;
|
||||||
|
}
|
||||||
|
|
||||||
$container = [];
|
$container = [];
|
||||||
$containerId = clicd_container_api_id($params, $container);
|
$containerId = clicd_container_api_id($params, $container);
|
||||||
@@ -1526,6 +1530,7 @@ function clicd_firewall_ajax($params)
|
|||||||
// update
|
// update
|
||||||
$enabled = clicd_param_value($input, 'enabled', 'true');
|
$enabled = clicd_param_value($input, 'enabled', 'true');
|
||||||
$enabled = filter_var($enabled, FILTER_VALIDATE_BOOLEAN);
|
$enabled = filter_var($enabled, FILTER_VALIDATE_BOOLEAN);
|
||||||
|
$defaultAction = strtoupper(trim((string)clicd_param_value($input, 'default_action', '')));
|
||||||
$rules = clicd_param_value($input, 'rules', '[]');
|
$rules = clicd_param_value($input, 'rules', '[]');
|
||||||
|
|
||||||
if (is_string($rules)) {
|
if (is_string($rules)) {
|
||||||
@@ -1542,6 +1547,9 @@ function clicd_firewall_ajax($params)
|
|||||||
'enabled' => $enabled,
|
'enabled' => $enabled,
|
||||||
'rules' => $rules,
|
'rules' => $rules,
|
||||||
];
|
];
|
||||||
|
if (in_array($defaultAction, ['ACCEPT', 'DROP'], true)) {
|
||||||
|
$payload['default_action'] = $defaultAction;
|
||||||
|
}
|
||||||
|
|
||||||
$call = clicd_request_debug($params, '/api/v1/containers/' . rawurlencode($containerId) . '/firewall', $payload, 'PUT', 30);
|
$call = clicd_request_debug($params, '/api/v1/containers/' . rawurlencode($containerId) . '/firewall', $payload, 'PUT', 30);
|
||||||
$debug[] = $call['debug'];
|
$debug[] = $call['debug'];
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -29,8 +30,9 @@ func getFirewall(w http.ResponseWriter, r *http.Request, id int) {
|
|||||||
jsonResponse(w, http.StatusOK, APIResponse{
|
jsonResponse(w, http.StatusOK, APIResponse{
|
||||||
Success: true,
|
Success: true,
|
||||||
Data: map[string]interface{}{
|
Data: map[string]interface{}{
|
||||||
"enabled": c.FirewallEnabled,
|
"enabled": c.FirewallEnabled,
|
||||||
"rules": c.FirewallRules,
|
"default_action": normalizeFirewallDefaultAction(c.FirewallDefaultAction),
|
||||||
|
"rules": c.FirewallRules,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -43,17 +45,32 @@ func updateFirewall(w http.ResponseWriter, r *http.Request, id int) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var req struct {
|
var req struct {
|
||||||
Enabled *bool `json:"enabled"`
|
Enabled *bool `json:"enabled"`
|
||||||
Rules *[]config.FirewallRule `json:"rules"`
|
DefaultAction *string `json:"default_action"`
|
||||||
|
Rules *[]config.FirewallRule `json:"rules"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
oldEnabled := c.FirewallEnabled
|
||||||
|
oldDefaultAction := c.FirewallDefaultAction
|
||||||
|
oldRules := append([]config.FirewallRule(nil), c.FirewallRules...)
|
||||||
|
|
||||||
if req.Enabled != nil {
|
if req.Enabled != nil {
|
||||||
c.FirewallEnabled = *req.Enabled
|
c.FirewallEnabled = *req.Enabled
|
||||||
}
|
}
|
||||||
|
if req.DefaultAction != nil {
|
||||||
|
action := normalizeFirewallDefaultAction(*req.DefaultAction)
|
||||||
|
if action == "" {
|
||||||
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid default action"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.FirewallDefaultAction = action
|
||||||
|
} else if strings.TrimSpace(c.FirewallDefaultAction) == "" {
|
||||||
|
c.FirewallDefaultAction = "DROP"
|
||||||
|
}
|
||||||
if req.Rules != nil {
|
if req.Rules != nil {
|
||||||
// Validate and assign IDs to new rules
|
// Validate and assign IDs to new rules
|
||||||
rules := *req.Rules
|
rules := *req.Rules
|
||||||
@@ -61,9 +78,14 @@ func updateFirewall(w http.ResponseWriter, r *http.Request, id int) {
|
|||||||
rules[i].Direction = strings.ToLower(strings.TrimSpace(rules[i].Direction))
|
rules[i].Direction = strings.ToLower(strings.TrimSpace(rules[i].Direction))
|
||||||
rules[i].Protocol = strings.ToLower(strings.TrimSpace(rules[i].Protocol))
|
rules[i].Protocol = strings.ToLower(strings.TrimSpace(rules[i].Protocol))
|
||||||
rules[i].Action = strings.ToUpper(strings.TrimSpace(rules[i].Action))
|
rules[i].Action = strings.ToUpper(strings.TrimSpace(rules[i].Action))
|
||||||
|
rules[i].Network = normalizeFirewallNetwork(rules[i].Network)
|
||||||
rules[i].SourceIP = strings.TrimSpace(rules[i].SourceIP)
|
rules[i].SourceIP = strings.TrimSpace(rules[i].SourceIP)
|
||||||
rules[i].Port = strings.TrimSpace(rules[i].Port)
|
rules[i].Port = strings.TrimSpace(rules[i].Port)
|
||||||
|
|
||||||
|
if rules[i].Network == "" {
|
||||||
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid network"})
|
||||||
|
return
|
||||||
|
}
|
||||||
if rules[i].Direction != "in" && rules[i].Direction != "out" {
|
if rules[i].Direction != "in" && rules[i].Direction != "out" {
|
||||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid direction: " + rules[i].Direction})
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid direction: " + rules[i].Direction})
|
||||||
return
|
return
|
||||||
@@ -76,11 +98,21 @@ func updateFirewall(w http.ResponseWriter, r *http.Request, id int) {
|
|||||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid action: " + rules[i].Action})
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid action: " + rules[i].Action})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if rules[i].ID == "" {
|
if rules[i].SourceIP != "" {
|
||||||
|
if err := validateFirewallIPSpec(rules[i].SourceIP, rules[i].Network); err != nil {
|
||||||
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid IP: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if rules[i].ID == "" || strings.HasPrefix(rules[i].ID, "tmp-") {
|
||||||
rules[i].ID = generateFirewallRuleID()
|
rules[i].ID = generateFirewallRuleID()
|
||||||
}
|
}
|
||||||
// Validate port spec
|
// Validate port spec
|
||||||
if rules[i].Port != "" {
|
if rules[i].Port != "" {
|
||||||
|
if rules[i].Protocol != "tcp" && rules[i].Protocol != "udp" {
|
||||||
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Ports are only supported for TCP and UDP rules"})
|
||||||
|
return
|
||||||
|
}
|
||||||
if err := validatePortSpec(rules[i].Port); err != nil {
|
if err := validatePortSpec(rules[i].Port); err != nil {
|
||||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid port: " + err.Error()})
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid port: " + err.Error()})
|
||||||
return
|
return
|
||||||
@@ -90,11 +122,14 @@ func updateFirewall(w http.ResponseWriter, r *http.Request, id int) {
|
|||||||
c.FirewallRules = rules
|
c.FirewallRules = rules
|
||||||
}
|
}
|
||||||
|
|
||||||
config.SaveConfig()
|
|
||||||
|
|
||||||
// Apply firewall rules to iptables if container is running
|
// Apply firewall rules to iptables if container is running
|
||||||
if c.Status == "running" {
|
if c.Status == "running" {
|
||||||
if err := lxc.ApplyFirewallRules(id); err != nil {
|
if err := lxc.ApplyFirewallRules(id); err != nil {
|
||||||
|
c.FirewallEnabled = oldEnabled
|
||||||
|
c.FirewallDefaultAction = oldDefaultAction
|
||||||
|
c.FirewallRules = oldRules
|
||||||
|
_ = lxc.ApplyFirewallRules(id)
|
||||||
|
config.SaveConfig()
|
||||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to apply firewall rules: " + err.Error()})
|
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to apply firewall rules: " + err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -102,28 +137,54 @@ func updateFirewall(w http.ResponseWriter, r *http.Request, id int) {
|
|||||||
// If disabled and not running, clean any lingering rules
|
// If disabled and not running, clean any lingering rules
|
||||||
lxc.CleanFirewallRules(id)
|
lxc.CleanFirewallRules(id)
|
||||||
}
|
}
|
||||||
|
config.SaveConfig()
|
||||||
|
|
||||||
jsonResponse(w, http.StatusOK, APIResponse{
|
jsonResponse(w, http.StatusOK, APIResponse{
|
||||||
Success: true,
|
Success: true,
|
||||||
Message: "Firewall updated",
|
Message: "Firewall updated",
|
||||||
Data: map[string]interface{}{
|
Data: map[string]interface{}{
|
||||||
"enabled": c.FirewallEnabled,
|
"enabled": c.FirewallEnabled,
|
||||||
"rules": c.FirewallRules,
|
"default_action": normalizeFirewallDefaultAction(c.FirewallDefaultAction),
|
||||||
|
"rules": c.FirewallRules,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeFirewallDefaultAction(action string) string {
|
||||||
|
action = strings.ToUpper(strings.TrimSpace(action))
|
||||||
|
if action == "ACCEPT" || action == "DROP" {
|
||||||
|
return action
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeFirewallNetwork(network string) string {
|
||||||
|
network = strings.ToLower(strings.TrimSpace(network))
|
||||||
|
switch network {
|
||||||
|
case "", "ipv4", "nat4":
|
||||||
|
return "ipv4"
|
||||||
|
case "ipv6":
|
||||||
|
return "ipv6"
|
||||||
|
case "all", "both":
|
||||||
|
return "all"
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func validatePortSpec(port string) error {
|
func validatePortSpec(port string) error {
|
||||||
port = strings.TrimSpace(port)
|
port = strings.TrimSpace(port)
|
||||||
if port == "" {
|
if port == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
// Support: "22", "80,443", "8000-9000", "80,443,8000-9000"
|
// Support: "22", "80,443", "8000-9000", "80,443,8000-9000"
|
||||||
|
partCount := 0
|
||||||
for _, part := range strings.Split(port, ",") {
|
for _, part := range strings.Split(port, ",") {
|
||||||
part = strings.TrimSpace(part)
|
part = strings.TrimSpace(part)
|
||||||
if part == "" {
|
if part == "" {
|
||||||
continue
|
return &portValidationError{port}
|
||||||
}
|
}
|
||||||
|
partCount++
|
||||||
if strings.Contains(part, "-") {
|
if strings.Contains(part, "-") {
|
||||||
// Range
|
// Range
|
||||||
bounds := strings.SplitN(part, "-", 2)
|
bounds := strings.SplitN(part, "-", 2)
|
||||||
@@ -135,6 +196,9 @@ func validatePortSpec(port string) error {
|
|||||||
if err != nil || hi < 1 || hi > 65535 {
|
if err != nil || hi < 1 || hi > 65535 {
|
||||||
return &portValidationError{part}
|
return &portValidationError{part}
|
||||||
}
|
}
|
||||||
|
if hi < lo {
|
||||||
|
return &portValidationError{part}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
p, err := strconv.Atoi(part)
|
p, err := strconv.Atoi(part)
|
||||||
if err != nil || p < 1 || p > 65535 {
|
if err != nil || p < 1 || p > 65535 {
|
||||||
@@ -142,9 +206,48 @@ func validatePortSpec(port string) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if partCount > 15 {
|
||||||
|
return &portValidationError{"too many ports; maximum 15 items per rule"}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func validateFirewallIPSpec(value string, network string) error {
|
||||||
|
var addr netip.Addr
|
||||||
|
if strings.Contains(value, "/") {
|
||||||
|
prefix, err := netip.ParsePrefix(value)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
addr = prefix.Addr()
|
||||||
|
} else {
|
||||||
|
parsed, err := netip.ParseAddr(value)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
addr = parsed
|
||||||
|
}
|
||||||
|
switch network {
|
||||||
|
case "ipv4":
|
||||||
|
if !addr.Is4() {
|
||||||
|
return &ipValidationError{"IPv4 rule requires an IPv4 address or CIDR: " + value}
|
||||||
|
}
|
||||||
|
case "ipv6":
|
||||||
|
if !addr.Is6() || addr.Is4In6() {
|
||||||
|
return &ipValidationError{"IPv6 rule requires an IPv6 address or CIDR: " + value}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type ipValidationError struct {
|
||||||
|
value string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *ipValidationError) Error() string {
|
||||||
|
return e.value
|
||||||
|
}
|
||||||
|
|
||||||
type portValidationError struct {
|
type portValidationError struct {
|
||||||
port string
|
port string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,11 +24,12 @@ type PortMapping struct {
|
|||||||
|
|
||||||
type FirewallRule struct {
|
type FirewallRule struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Direction string `json:"direction"` // "in" or "out"
|
Network string `json:"network,omitempty"` // "ipv4", "ipv6", or "all"; empty defaults to "ipv4"
|
||||||
Protocol string `json:"protocol"` // "tcp", "udp", "icmp", "all"
|
Direction string `json:"direction"` // "in" or "out"
|
||||||
Port string `json:"port"` // "" = all, "22", "80,443", "8000-9000"
|
Protocol string `json:"protocol"` // "tcp", "udp", "icmp", "all"
|
||||||
SourceIP string `json:"source_ip"` // "" = any
|
Port string `json:"port"` // "" = all, "22", "80,443", "8000-9000"
|
||||||
Action string `json:"action"` // "ACCEPT" or "DROP"
|
SourceIP string `json:"source_ip"` // "" = any
|
||||||
|
Action string `json:"action"` // "ACCEPT" or "DROP"
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
}
|
}
|
||||||
@@ -136,7 +137,8 @@ type Container struct {
|
|||||||
PortMappings []PortMapping `json:"port_mappings"`
|
PortMappings []PortMapping `json:"port_mappings"`
|
||||||
PortMappingLimit int `json:"port_mapping_limit"`
|
PortMappingLimit int `json:"port_mapping_limit"`
|
||||||
FirewallEnabled bool `json:"firewall_enabled"`
|
FirewallEnabled bool `json:"firewall_enabled"`
|
||||||
FirewallRules []FirewallRule `json:"firewall_rules"`
|
FirewallDefaultAction string `json:"firewall_default_action"`
|
||||||
|
FirewallRules []FirewallRule `json:"firewall_rules"`
|
||||||
SnapshotLimit int `json:"snapshot_limit"`
|
SnapshotLimit int `json:"snapshot_limit"`
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
ExpiresAt string `json:"expires_at"`
|
ExpiresAt string `json:"expires_at"`
|
||||||
|
|||||||
@@ -365,6 +365,7 @@ func ensureSchemaMigrations() error {
|
|||||||
{"container_public_ipv4s", "prefix_len", "INTEGER"},
|
{"container_public_ipv4s", "prefix_len", "INTEGER"},
|
||||||
{"container_public_ipv4s", "gateway", "TEXT"},
|
{"container_public_ipv4s", "gateway", "TEXT"},
|
||||||
{"containers", "firewall_enabled", "INTEGER NOT NULL DEFAULT 0"},
|
{"containers", "firewall_enabled", "INTEGER NOT NULL DEFAULT 0"},
|
||||||
|
{"containers", "firewall_default_action", "TEXT NOT NULL DEFAULT 'DROP'"},
|
||||||
{"containers", "firewall_rules", "TEXT"},
|
{"containers", "firewall_rules", "TEXT"},
|
||||||
} {
|
} {
|
||||||
if err := ensureColumn(column.table, column.name, column.def); err != nil {
|
if err := ensureColumn(column.table, column.name, column.def); err != nil {
|
||||||
@@ -586,8 +587,8 @@ func saveContainers(tx *sql.Tx) error {
|
|||||||
snapshot_schedule_enabled, snapshot_schedule_interval_hours, snapshot_schedule_time,
|
snapshot_schedule_enabled, snapshot_schedule_interval_hours, snapshot_schedule_time,
|
||||||
snapshot_schedule_last_run, snapshot_schedule_next_run, snapshot_schedule_created_by,
|
snapshot_schedule_last_run, snapshot_schedule_next_run, snapshot_schedule_created_by,
|
||||||
policy_blocked, policy_blocked_reason, policy_blocked_at,
|
policy_blocked, policy_blocked_reason, policy_blocked_at,
|
||||||
firewall_enabled, firewall_rules
|
firewall_enabled, firewall_default_action, firewall_rules
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
c.ID, c.UUID, c.Name, c.Virtualization, c.LXCName, c.KVMName, c.DiskImage, c.MACAddress, c.Template,
|
c.ID, c.UUID, c.Name, c.Virtualization, c.LXCName, c.KVMName, c.DiskImage, c.MACAddress, c.Template,
|
||||||
c.VCPU, c.RAMMB, c.DiskGB, c.NetworkBWMbps, c.MonthlyTrafficGB, c.TrafficMode, c.TrafficInGB,
|
c.VCPU, c.RAMMB, c.DiskGB, c.NetworkBWMbps, c.MonthlyTrafficGB, c.TrafficMode, c.TrafficInGB,
|
||||||
c.TrafficOutGB, c.TrafficUsedRX, c.TrafficUsedTX, c.TrafficResetDate, c.IOSpeedMBps,
|
c.TrafficOutGB, c.TrafficUsedRX, c.TrafficUsedTX, c.TrafficResetDate, c.IOSpeedMBps,
|
||||||
@@ -596,7 +597,7 @@ func saveContainers(tx *sql.Tx) error {
|
|||||||
boolInt(c.SnapshotScheduleEnabled), c.SnapshotScheduleIntervalHours, c.SnapshotScheduleTime,
|
boolInt(c.SnapshotScheduleEnabled), c.SnapshotScheduleIntervalHours, c.SnapshotScheduleTime,
|
||||||
c.SnapshotScheduleLastRun, c.SnapshotScheduleNextRun, c.SnapshotScheduleCreatedBy,
|
c.SnapshotScheduleLastRun, c.SnapshotScheduleNextRun, c.SnapshotScheduleCreatedBy,
|
||||||
boolInt(c.PolicyBlocked), c.PolicyBlockedReason, c.PolicyBlockedAt,
|
boolInt(c.PolicyBlocked), c.PolicyBlockedReason, c.PolicyBlockedAt,
|
||||||
boolInt(c.FirewallEnabled), marshalFirewallRules(c.FirewallRules),
|
boolInt(c.FirewallEnabled), normalizeFirewallDefaultAction(c.FirewallDefaultAction), marshalFirewallRules(c.FirewallRules),
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -794,7 +795,7 @@ func loadContainers() ([]Container, error) {
|
|||||||
snapshot_schedule_enabled, snapshot_schedule_interval_hours, snapshot_schedule_time,
|
snapshot_schedule_enabled, snapshot_schedule_interval_hours, snapshot_schedule_time,
|
||||||
snapshot_schedule_last_run, snapshot_schedule_next_run, snapshot_schedule_created_by,
|
snapshot_schedule_last_run, snapshot_schedule_next_run, snapshot_schedule_created_by,
|
||||||
policy_blocked, policy_blocked_reason, policy_blocked_at,
|
policy_blocked, policy_blocked_reason, policy_blocked_at,
|
||||||
firewall_enabled, firewall_rules
|
firewall_enabled, firewall_default_action, firewall_rules
|
||||||
FROM containers ORDER BY id`)
|
FROM containers ORDER BY id`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -805,6 +806,7 @@ func loadContainers() ([]Container, error) {
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var c Container
|
var c Container
|
||||||
var scheduleEnabled, policyBlocked, firewallEnabled int
|
var scheduleEnabled, policyBlocked, firewallEnabled int
|
||||||
|
var firewallDefaultAction string
|
||||||
var firewallRulesJSON sql.NullString
|
var firewallRulesJSON sql.NullString
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&c.ID, &c.UUID, &c.Name, &c.Virtualization, &c.LXCName, &c.KVMName, &c.DiskImage, &c.MACAddress, &c.Template,
|
&c.ID, &c.UUID, &c.Name, &c.Virtualization, &c.LXCName, &c.KVMName, &c.DiskImage, &c.MACAddress, &c.Template,
|
||||||
@@ -815,13 +817,14 @@ func loadContainers() ([]Container, error) {
|
|||||||
&scheduleEnabled, &c.SnapshotScheduleIntervalHours, &c.SnapshotScheduleTime,
|
&scheduleEnabled, &c.SnapshotScheduleIntervalHours, &c.SnapshotScheduleTime,
|
||||||
&c.SnapshotScheduleLastRun, &c.SnapshotScheduleNextRun, &c.SnapshotScheduleCreatedBy,
|
&c.SnapshotScheduleLastRun, &c.SnapshotScheduleNextRun, &c.SnapshotScheduleCreatedBy,
|
||||||
&policyBlocked, &c.PolicyBlockedReason, &c.PolicyBlockedAt,
|
&policyBlocked, &c.PolicyBlockedReason, &c.PolicyBlockedAt,
|
||||||
&firewallEnabled, &firewallRulesJSON,
|
&firewallEnabled, &firewallDefaultAction, &firewallRulesJSON,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
c.SnapshotScheduleEnabled = scheduleEnabled != 0
|
c.SnapshotScheduleEnabled = scheduleEnabled != 0
|
||||||
c.PolicyBlocked = policyBlocked != 0
|
c.PolicyBlocked = policyBlocked != 0
|
||||||
c.FirewallEnabled = firewallEnabled != 0
|
c.FirewallEnabled = firewallEnabled != 0
|
||||||
|
c.FirewallDefaultAction = normalizeFirewallDefaultAction(firewallDefaultAction)
|
||||||
if firewallRulesJSON.Valid && strings.TrimSpace(firewallRulesJSON.String) != "" {
|
if firewallRulesJSON.Valid && strings.TrimSpace(firewallRulesJSON.String) != "" {
|
||||||
_ = json.Unmarshal([]byte(firewallRulesJSON.String), &c.FirewallRules)
|
_ = json.Unmarshal([]byte(firewallRulesJSON.String), &c.FirewallRules)
|
||||||
}
|
}
|
||||||
@@ -1189,6 +1192,14 @@ func marshalFirewallRules(rules []FirewallRule) interface{} {
|
|||||||
return string(data)
|
return string(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeFirewallDefaultAction(action string) string {
|
||||||
|
action = strings.ToUpper(strings.TrimSpace(action))
|
||||||
|
if action == "ACCEPT" {
|
||||||
|
return "ACCEPT"
|
||||||
|
}
|
||||||
|
return "DROP"
|
||||||
|
}
|
||||||
|
|
||||||
func boolPtrInt(value *bool) interface{} {
|
func boolPtrInt(value *bool) interface{} {
|
||||||
if value == nil {
|
if value == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -3231,6 +3231,11 @@ func (m *Manager) applyIPv6Runtime(c *config.Container) error {
|
|||||||
}
|
}
|
||||||
ensureKVMIPv6NAT66(assignment.Address, uplink)
|
ensureKVMIPv6NAT66(assignment.Address, uplink)
|
||||||
}
|
}
|
||||||
|
if c.Status == "running" {
|
||||||
|
if err := lxc.ApplyFirewallRules(c.ID); err != nil {
|
||||||
|
fmt.Printf("Warning: failed to re-apply firewall rules after KVM IPv6 setup for %s: %v\n", c.Name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3307,7 +3312,7 @@ func ensureKVMIPv6ForwardRules(ipv6 string, bridge string) {
|
|||||||
}
|
}
|
||||||
for _, rule := range rules {
|
for _, rule := range rules {
|
||||||
check := append([]string{"-C"}, rule...)
|
check := append([]string{"-C"}, rule...)
|
||||||
add := append([]string{"-I"}, append([]string{rule[0], "1"}, rule[1:]...)...)
|
add := append([]string{"-A"}, rule...)
|
||||||
if exec.Command("ip6tables", check...).Run() != nil {
|
if exec.Command("ip6tables", check...).Run() != nil {
|
||||||
exec.Command("ip6tables", add...).Run()
|
exec.Command("ip6tables", add...).Run()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1610,6 +1610,9 @@ func (m *Manager) ApplyIPv6(id int) error {
|
|||||||
ensureIPv6NAT66(assignment.Address, uplink)
|
ensureIPv6NAT66(assignment.Address, uplink)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if err := ApplyFirewallRules(c.ID); err != nil {
|
||||||
|
fmt.Printf("Warning: failed to re-apply firewall rules after IPv6 setup for %s: %v\n", c.Name, err)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+251
-49
@@ -67,6 +67,10 @@ func (m *Manager) ApplyPortMappings(id int) error {
|
|||||||
|
|
||||||
applyIPv4EgressPolicy(c, bridge, subnet, tag)
|
applyIPv4EgressPolicy(c, bridge, subnet, tag)
|
||||||
|
|
||||||
|
if err := ApplyFirewallRules(id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,8 +264,8 @@ func EnsureForwardRules(bridge string) {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
insertArgs := append([]string{"-I", "FORWARD", "1"}, args...)
|
appendArgs := append([]string{"-A", "FORWARD"}, args...)
|
||||||
exec.Command("iptables", insertArgs...).Run()
|
exec.Command("iptables", appendArgs...).Run()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -576,6 +580,9 @@ func CleanFirewallRules(id int) {
|
|||||||
cmd := exec.Command("bash", "-c",
|
cmd := exec.Command("bash", "-c",
|
||||||
fmt.Sprintf("iptables -S FORWARD 2>/dev/null | grep 'clicd-%s-fw-' | sed 's/^-A /-D /' | while read rule; do iptables $rule; done", tag))
|
fmt.Sprintf("iptables -S FORWARD 2>/dev/null | grep 'clicd-%s-fw-' | sed 's/^-A /-D /' | while read rule; do iptables $rule; done", tag))
|
||||||
cmd.CombinedOutput()
|
cmd.CombinedOutput()
|
||||||
|
cmd = exec.Command("bash", "-c",
|
||||||
|
fmt.Sprintf("ip6tables -S FORWARD 2>/dev/null | grep 'clicd-%s-fw-' | sed 's/^-A /-D /' | while read rule; do ip6tables $rule; done", tag))
|
||||||
|
cmd.CombinedOutput()
|
||||||
|
|
||||||
// Also remove legacy default policy rules (without specific rule ID)
|
// Also remove legacy default policy rules (without specific rule ID)
|
||||||
for _, suffix := range []string{"default-in", "default-out"} {
|
for _, suffix := range []string{"default-in", "default-out"} {
|
||||||
@@ -584,6 +591,9 @@ func CleanFirewallRules(id int) {
|
|||||||
"-m", "comment", "--comment", fmt.Sprintf("clicd-%s-fw-%s-%s", tag, suffix, proto),
|
"-m", "comment", "--comment", fmt.Sprintf("clicd-%s-fw-%s-%s", tag, suffix, proto),
|
||||||
).CombinedOutput()
|
).CombinedOutput()
|
||||||
}
|
}
|
||||||
|
exec.Command("ip6tables", "-D", "FORWARD",
|
||||||
|
"-m", "comment", "--comment", fmt.Sprintf("clicd-%s-fw-%s", tag, suffix),
|
||||||
|
).CombinedOutput()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -606,42 +616,140 @@ func ApplyFirewallRules(id int) error {
|
|||||||
if c.IsKVM() {
|
if c.IsKVM() {
|
||||||
bridge = "virbr0"
|
bridge = "virbr0"
|
||||||
}
|
}
|
||||||
containerIP := c.IP
|
containerIP := strings.TrimSpace(c.IP)
|
||||||
if containerIP == "" {
|
containerIPv6s := firewallIPv6Addresses(c)
|
||||||
|
if containerIP == "" && len(containerIPv6s) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
tag := clicdTag(id)
|
tag := clicdTag(id)
|
||||||
|
|
||||||
// Apply default DROP policy first (inserted at position 1).
|
defaultAction := normalizeFirewallDefaultAction(c.FirewallDefaultAction)
|
||||||
// Then insert ACCEPT rules (also at position 1), which pushes the DROPs down.
|
if defaultAction == "DROP" {
|
||||||
// Final order: ACCEPT rules on top, DROP defaults below, bridge ACCEPT rules at the bottom.
|
if containerIP != "" {
|
||||||
applyDefaultFirewallPolicy(tag, bridge, containerIP)
|
if err := applyDefaultFirewallPolicy(tag, bridge, containerIP); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := applyDefaultFirewallIPv6Policy(tag, bridge, containerIPv6s); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for _, rule := range c.FirewallRules {
|
for i := len(c.FirewallRules) - 1; i >= 0; i-- {
|
||||||
|
rule := c.FirewallRules[i]
|
||||||
if !rule.Enabled {
|
if !rule.Enabled {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if err := applyOneFirewallRule(tag, bridge, containerIP, rule); err != nil {
|
if containerIP != "" && firewallRuleAppliesToFamily(rule, true) {
|
||||||
fmt.Printf("Warning: failed to apply firewall rule %s for container %d: %v\n", rule.ID, id, err)
|
if err := applyOneFirewallRule(tag, bridge, containerIP, rule); err != nil {
|
||||||
|
return fmt.Errorf("failed to apply firewall rule %s for container %d: %w", rule.ID, id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(containerIPv6s) > 0 && firewallRuleAppliesToFamily(rule, false) {
|
||||||
|
if err := applyOneFirewallIPv6Rule(tag, bridge, containerIPv6s, rule); err != nil {
|
||||||
|
return fmt.Errorf("failed to apply IPv6 firewall rule %s for container %d: %w", rule.ID, id, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeFirewallDefaultAction(action string) string {
|
||||||
|
action = strings.ToUpper(strings.TrimSpace(action))
|
||||||
|
if action == "ACCEPT" {
|
||||||
|
return "ACCEPT"
|
||||||
|
}
|
||||||
|
return "DROP"
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeFirewallNetwork(network string) string {
|
||||||
|
network = strings.ToLower(strings.TrimSpace(network))
|
||||||
|
switch network {
|
||||||
|
case "", "ipv4", "nat4":
|
||||||
|
return "ipv4"
|
||||||
|
case "ipv6":
|
||||||
|
return "ipv6"
|
||||||
|
case "all", "both":
|
||||||
|
return "all"
|
||||||
|
default:
|
||||||
|
return "ipv4"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func firewallRuleAppliesToFamily(rule config.FirewallRule, ipv4 bool) bool {
|
||||||
|
network := normalizeFirewallNetwork(rule.Network)
|
||||||
|
if network == "ipv4" {
|
||||||
|
return ipv4
|
||||||
|
}
|
||||||
|
if network == "ipv6" {
|
||||||
|
return !ipv4
|
||||||
|
}
|
||||||
|
if rule.SourceIP == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
addr := firewallIPSpecAddr(rule.SourceIP)
|
||||||
|
if !addr.IsValid() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if ipv4 {
|
||||||
|
return addr.Is4()
|
||||||
|
}
|
||||||
|
return addr.Is6() && !addr.Is4In6()
|
||||||
|
}
|
||||||
|
|
||||||
|
func firewallIPSpecAddr(value string) netip.Addr {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" {
|
||||||
|
return netip.Addr{}
|
||||||
|
}
|
||||||
|
if strings.Contains(value, "/") {
|
||||||
|
prefix, err := netip.ParsePrefix(value)
|
||||||
|
if err != nil {
|
||||||
|
return netip.Addr{}
|
||||||
|
}
|
||||||
|
return prefix.Addr()
|
||||||
|
}
|
||||||
|
addr, err := netip.ParseAddr(value)
|
||||||
|
if err != nil {
|
||||||
|
return netip.Addr{}
|
||||||
|
}
|
||||||
|
return addr
|
||||||
|
}
|
||||||
|
|
||||||
|
func firewallIPv6Addresses(c *config.Container) []string {
|
||||||
|
if c == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
c.NormalizeNetworkAssignments()
|
||||||
|
seen := map[string]bool{}
|
||||||
|
result := []string{}
|
||||||
|
for _, assignment := range c.IPv6Addresses {
|
||||||
|
ip := strings.TrimSpace(assignment.Address)
|
||||||
|
if ip == "" || seen[ip] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if addr, err := netip.ParseAddr(ip); err == nil && addr.Is6() && !addr.Is4In6() {
|
||||||
|
seen[ip] = true
|
||||||
|
result = append(result, ip)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
func applyOneFirewallRule(tag, bridge, containerIP string, rule config.FirewallRule) error {
|
func applyOneFirewallRule(tag, bridge, containerIP string, rule config.FirewallRule) error {
|
||||||
commentTag := fmt.Sprintf("clicd-%s-fw-%s", tag, rule.ID)
|
commentTag := fmt.Sprintf("clicd-%s-fw-%s", tag, rule.ID)
|
||||||
|
|
||||||
// Build base iptables args
|
// Build base iptables args
|
||||||
args := []string{"-I", "FORWARD", "1"}
|
args := []string{"-I", "FORWARD", "1"}
|
||||||
|
|
||||||
// Direction: in = traffic arriving at container (-i bridge -d containerIP)
|
// Direction: in = traffic arriving at container (-o bridge -d containerIP)
|
||||||
// out = traffic leaving container (-o bridge -s containerIP)
|
// out = traffic leaving container (-i bridge -s containerIP)
|
||||||
switch rule.Direction {
|
switch rule.Direction {
|
||||||
case "in":
|
case "in":
|
||||||
args = append(args, "-i", bridge, "-d", containerIP+"/32")
|
args = append(args, "-o", bridge, "-d", containerIP+"/32")
|
||||||
case "out":
|
case "out":
|
||||||
args = append(args, "-o", bridge, "-s", containerIP+"/32")
|
args = append(args, "-i", bridge, "-s", containerIP+"/32")
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("invalid direction: %s", rule.Direction)
|
return fmt.Errorf("invalid direction: %s", rule.Direction)
|
||||||
}
|
}
|
||||||
@@ -662,7 +770,7 @@ func applyOneFirewallRule(tag, bridge, containerIP string, rule config.FirewallR
|
|||||||
if rule.Port != "" && (rule.Protocol == "tcp" || rule.Protocol == "udp") {
|
if rule.Port != "" && (rule.Protocol == "tcp" || rule.Protocol == "udp") {
|
||||||
// For "in" direction, traffic going TO the container uses --dport
|
// For "in" direction, traffic going TO the container uses --dport
|
||||||
// For "out" direction, traffic going FROM the container uses --dport (destination port on remote)
|
// For "out" direction, traffic going FROM the container uses --dport (destination port on remote)
|
||||||
args = append(args, "--dport", normalizePortSpec(rule.Port))
|
args = append(args, firewallPortArgs(rule.Port)...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Source IP filter (for "out" direction, this matches the remote source; for "in", it matches the sender)
|
// Source IP filter (for "out" direction, this matches the remote source; for "in", it matches the sender)
|
||||||
@@ -693,51 +801,145 @@ func applyOneFirewallRule(tag, bridge, containerIP string, rule config.FirewallR
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func applyOneFirewallIPv6Rule(tag, bridge string, containerIPs []string, rule config.FirewallRule) error {
|
||||||
|
for _, containerIP := range containerIPs {
|
||||||
|
commentTag := fmt.Sprintf("clicd-%s-fw-%s-v6-%s", tag, rule.ID, firewallCommentIPTag(containerIP))
|
||||||
|
args := []string{"-I", "FORWARD", "1"}
|
||||||
|
|
||||||
|
switch rule.Direction {
|
||||||
|
case "in":
|
||||||
|
args = append(args, "-o", bridge, "-d", containerIP+"/128")
|
||||||
|
case "out":
|
||||||
|
args = append(args, "-i", bridge, "-s", containerIP+"/128")
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("invalid direction: %s", rule.Direction)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch rule.Protocol {
|
||||||
|
case "tcp", "udp":
|
||||||
|
args = append(args, "-p", rule.Protocol)
|
||||||
|
case "icmp":
|
||||||
|
args = append(args, "-p", "ipv6-icmp")
|
||||||
|
case "all":
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("invalid protocol: %s", rule.Protocol)
|
||||||
|
}
|
||||||
|
|
||||||
|
if rule.Port != "" && (rule.Protocol == "tcp" || rule.Protocol == "udp") {
|
||||||
|
args = append(args, firewallPortArgs(rule.Port)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
if rule.SourceIP != "" {
|
||||||
|
switch rule.Direction {
|
||||||
|
case "in":
|
||||||
|
args = append(args, "-s", rule.SourceIP)
|
||||||
|
case "out":
|
||||||
|
args = append(args, "-d", rule.SourceIP)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
action := "DROP"
|
||||||
|
if rule.Action == "ACCEPT" {
|
||||||
|
action = "ACCEPT"
|
||||||
|
}
|
||||||
|
args = append(args, "-j", action)
|
||||||
|
args = append(args, "-m", "comment", "--comment", commentTag)
|
||||||
|
|
||||||
|
cmd := exec.Command("ip6tables", args...)
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("ip6tables error: %s", string(output))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func firewallPortArgs(port string) []string {
|
||||||
|
spec := normalizePortSpec(port)
|
||||||
|
if strings.Contains(spec, ",") {
|
||||||
|
return []string{"-m", "multiport", "--dports", spec}
|
||||||
|
}
|
||||||
|
return []string{"--dport", spec}
|
||||||
|
}
|
||||||
|
|
||||||
// normalizePortSpec converts user port input to iptables-compatible port spec.
|
// normalizePortSpec converts user port input to iptables-compatible port spec.
|
||||||
// "80,443" -> "80,443", "8000-9000" -> "8000:9000", "22" -> "22"
|
// "80,443" -> "80,443", "8000-9000" -> "8000:9000", "80,443,8000-9000" -> "80,443,8000:9000"
|
||||||
func normalizePortSpec(port string) string {
|
func normalizePortSpec(port string) string {
|
||||||
port = strings.TrimSpace(port)
|
port = strings.TrimSpace(port)
|
||||||
if port == "" {
|
if port == "" {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
// Convert comma-separated to iptables format (already valid)
|
parts := strings.Split(port, ",")
|
||||||
// Convert dash range to colon range: "8000-9000" -> "8000:9000"
|
for i, part := range parts {
|
||||||
if strings.Contains(port, "-") && !strings.Contains(port, ":") {
|
part = strings.TrimSpace(part)
|
||||||
parts := strings.SplitN(port, "-", 2)
|
if strings.Contains(part, "-") && !strings.Contains(part, ":") {
|
||||||
if len(parts) == 2 {
|
bounds := strings.SplitN(part, "-", 2)
|
||||||
return strings.TrimSpace(parts[0]) + ":" + strings.TrimSpace(parts[1])
|
if len(bounds) == 2 {
|
||||||
|
part = strings.TrimSpace(bounds[0]) + ":" + strings.TrimSpace(bounds[1])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
parts[i] = part
|
||||||
}
|
}
|
||||||
return port
|
return strings.Join(parts, ",")
|
||||||
}
|
}
|
||||||
|
|
||||||
func applyDefaultFirewallPolicy(tag, bridge, containerIP string) {
|
func applyDefaultFirewallPolicy(tag, bridge, containerIP string) error {
|
||||||
// Default DROP: inserted at position 1 so they sit above bridge ACCEPT rules.
|
defaults := [][]string{
|
||||||
// The user-defined ACCEPT rules (also at position 1) were inserted first,
|
{
|
||||||
// so they end up above these DROP defaults after the position-1 insertions.
|
|
||||||
for _, proto := range []string{"tcp", "udp"} {
|
|
||||||
args := []string{
|
|
||||||
"-I", "FORWARD", "1",
|
|
||||||
"-i", bridge,
|
|
||||||
"-d", containerIP + "/32",
|
|
||||||
"-p", proto,
|
|
||||||
"-j", "DROP",
|
|
||||||
"-m", "comment", "--comment", fmt.Sprintf("clicd-%s-fw-default-in-%s", tag, proto),
|
|
||||||
}
|
|
||||||
cmd := exec.Command("iptables", args...)
|
|
||||||
cmd.CombinedOutput()
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, proto := range []string{"tcp", "udp"} {
|
|
||||||
args := []string{
|
|
||||||
"-I", "FORWARD", "1",
|
"-I", "FORWARD", "1",
|
||||||
"-o", bridge,
|
"-o", bridge,
|
||||||
"-s", containerIP + "/32",
|
"-d", containerIP + "/32",
|
||||||
"-p", proto,
|
|
||||||
"-j", "DROP",
|
"-j", "DROP",
|
||||||
"-m", "comment", "--comment", fmt.Sprintf("clicd-%s-fw-default-out-%s", tag, proto),
|
"-m", "comment", "--comment", fmt.Sprintf("clicd-%s-fw-default-in", tag),
|
||||||
}
|
},
|
||||||
cmd := exec.Command("iptables", args...)
|
{
|
||||||
cmd.CombinedOutput()
|
"-I", "FORWARD", "1",
|
||||||
|
"-i", bridge,
|
||||||
|
"-s", containerIP + "/32",
|
||||||
|
"-j", "DROP",
|
||||||
|
"-m", "comment", "--comment", fmt.Sprintf("clicd-%s-fw-default-out", tag),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
for _, args := range defaults {
|
||||||
|
cmd := exec.Command("iptables", args...)
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("iptables default firewall error: %s", string(output))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyDefaultFirewallIPv6Policy(tag, bridge string, containerIPs []string) error {
|
||||||
|
for _, containerIP := range containerIPs {
|
||||||
|
defaults := [][]string{
|
||||||
|
{
|
||||||
|
"-I", "FORWARD", "1",
|
||||||
|
"-o", bridge,
|
||||||
|
"-d", containerIP + "/128",
|
||||||
|
"-j", "DROP",
|
||||||
|
"-m", "comment", "--comment", fmt.Sprintf("clicd-%s-fw-default-in-v6-%s", tag, firewallCommentIPTag(containerIP)),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"-I", "FORWARD", "1",
|
||||||
|
"-i", bridge,
|
||||||
|
"-s", containerIP + "/128",
|
||||||
|
"-j", "DROP",
|
||||||
|
"-m", "comment", "--comment", fmt.Sprintf("clicd-%s-fw-default-out-v6-%s", tag, firewallCommentIPTag(containerIP)),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, args := range defaults {
|
||||||
|
cmd := exec.Command("ip6tables", args...)
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("ip6tables default firewall error: %s", string(output))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func firewallCommentIPTag(ip string) string {
|
||||||
|
replacer := strings.NewReplacer(":", "_", ".", "_", "/", "_")
|
||||||
|
return replacer.Replace(ip)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -821,10 +821,11 @@ const requestBodySamples: Record<string, Record<string, unknown>> = {
|
|||||||
'POST /api/v1/security/check': { container_name: 'example-vm' },
|
'POST /api/v1/security/check': { container_name: 'example-vm' },
|
||||||
'PUT /api/v1/containers/{id}/firewall': {
|
'PUT /api/v1/containers/{id}/firewall': {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
default_action: 'DROP',
|
||||||
rules: [
|
rules: [
|
||||||
{ id: '', direction: 'in', protocol: 'tcp', port: '22', source_ip: '', action: 'ACCEPT', description: 'Allow SSH', enabled: true },
|
{ id: '', network: 'ipv4', direction: 'in', protocol: 'tcp', port: '22', source_ip: '', action: 'ACCEPT', description: 'Allow SSH over IPv4/NAT4', enabled: true },
|
||||||
{ id: '', direction: 'in', protocol: 'tcp', port: '80,443', source_ip: '', action: 'ACCEPT', description: 'Allow HTTP/HTTPS', enabled: true },
|
{ id: '', network: 'ipv6', direction: 'in', protocol: 'tcp', port: '22', source_ip: '', action: 'ACCEPT', description: 'Allow SSH over IPv6', enabled: true },
|
||||||
{ id: '', direction: 'out', protocol: 'tcp', port: '', source_ip: '', action: 'ACCEPT', description: 'Allow all outbound TCP', enabled: true },
|
{ id: '', network: 'all', direction: 'out', protocol: 'tcp', port: '', source_ip: '', action: 'ACCEPT', description: 'Allow outbound TCP', enabled: true },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
'PUT /api/v1/security/settings': { auto_shutdown: false },
|
'PUT /api/v1/security/settings': { auto_shutdown: false },
|
||||||
@@ -1039,10 +1040,11 @@ const responseSamples: Record<string, unknown> = {
|
|||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
default_action: 'DROP',
|
||||||
rules: [
|
rules: [
|
||||||
{ id: 'a1b2c3d4', direction: 'in', protocol: 'tcp', port: '22', source_ip: '', action: 'ACCEPT', description: 'Allow SSH', enabled: true },
|
{ id: 'a1b2c3d4', network: 'ipv4', direction: 'in', protocol: 'tcp', port: '22', source_ip: '', action: 'ACCEPT', description: 'Allow SSH over IPv4/NAT4', enabled: true },
|
||||||
{ id: 'e5f6g7h8', direction: 'in', protocol: 'tcp', port: '80,443', source_ip: '', action: 'ACCEPT', description: 'Allow HTTP/HTTPS', enabled: true },
|
{ id: 'e5f6g7h8', network: 'ipv6', direction: 'in', protocol: 'tcp', port: '22', source_ip: '', action: 'ACCEPT', description: 'Allow SSH over IPv6', enabled: true },
|
||||||
{ id: 'i9j0k1l2', direction: 'out', protocol: 'tcp', port: '', source_ip: '', action: 'ACCEPT', description: 'Allow all outbound TCP', enabled: true },
|
{ id: 'i9j0k1l2', network: 'all', direction: 'out', protocol: 'tcp', port: '', source_ip: '', action: 'ACCEPT', description: 'Allow outbound TCP', enabled: true },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -1051,10 +1053,11 @@ const responseSamples: Record<string, unknown> = {
|
|||||||
message: 'Firewall updated',
|
message: 'Firewall updated',
|
||||||
data: {
|
data: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
default_action: 'DROP',
|
||||||
rules: [
|
rules: [
|
||||||
{ id: 'a1b2c3d4', direction: 'in', protocol: 'tcp', port: '22', source_ip: '', action: 'ACCEPT', description: 'Allow SSH', enabled: true },
|
{ id: 'a1b2c3d4', network: 'ipv4', direction: 'in', protocol: 'tcp', port: '22', source_ip: '', action: 'ACCEPT', description: 'Allow SSH over IPv4/NAT4', enabled: true },
|
||||||
{ id: 'e5f6g7h8', direction: 'in', protocol: 'tcp', port: '80,443', source_ip: '', action: 'ACCEPT', description: 'Allow HTTP/HTTPS', enabled: true },
|
{ id: 'e5f6g7h8', network: 'ipv6', direction: 'in', protocol: 'tcp', port: '22', source_ip: '', action: 'ACCEPT', description: 'Allow SSH over IPv6', enabled: true },
|
||||||
{ id: 'i9j0k1l2', direction: 'out', protocol: 'tcp', port: '', source_ip: '', action: 'ACCEPT', description: 'Allow all outbound TCP', enabled: true },
|
{ id: 'i9j0k1l2', network: 'all', direction: 'out', protocol: 'tcp', port: '', source_ip: '', action: 'ACCEPT', description: 'Allow outbound TCP', enabled: true },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -1173,7 +1176,7 @@ function endpointNoteFor(key: string) {
|
|||||||
notes.push('批量创建的单个 containers[] 项支持与 POST /api/v1/containers 相同的网络和 SSH 认证字段。')
|
notes.push('批量创建的单个 containers[] 项支持与 POST /api/v1/containers 相同的网络和 SSH 认证字段。')
|
||||||
}
|
}
|
||||||
if (key === 'PUT /api/v1/containers/{id}/firewall') {
|
if (key === 'PUT /api/v1/containers/{id}/firewall') {
|
||||||
notes.push('启用防火墙后默认拒绝所有 TCP/UDP 入站和出站流量,仅放行 rules 中定义的规则。direction: in=入站, out=出站。action: ACCEPT=放行, DROP=拒绝。port 支持单端口(22)、多端口(80,443)、范围(8000-9000)。')
|
notes.push('兼容旧请求:default_action 可不传,不传时保留现有策略;rule.network 可不传,不传按 ipv4 处理。default_action: DROP=未命中规则时拒绝, ACCEPT=未命中规则时放行。network: ipv4=IPv4 NAT/公网 IPv4, ipv6=IPv6, all=同时应用到 IPv4 和 IPv6。NAT 入站规则的 port 填容器内端口,不是宿主机公网端口。')
|
||||||
}
|
}
|
||||||
if (key === 'POST /api/v1/batch-action') {
|
if (key === 'POST /api/v1/batch-action') {
|
||||||
notes.push('action=reinstall 时可追加 template_id、ssh_auth_mode、ssh_password、ssh_public_key;其他 action 会忽略这些重装字段。')
|
notes.push('action=reinstall 时可追加 template_id、ssh_auth_mode、ssh_password、ssh_public_key;其他 action 会忽略这些重装字段。')
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ import {
|
|||||||
HostInfo,
|
HostInfo,
|
||||||
TrafficInfo,
|
TrafficInfo,
|
||||||
getEnabledImages,
|
getEnabledImages,
|
||||||
|
getFirewall,
|
||||||
PortMapping,
|
PortMapping,
|
||||||
FirewallRule,
|
FirewallRule,
|
||||||
reinstallContainer,
|
reinstallContainer,
|
||||||
@@ -166,8 +167,10 @@ export default function ContainerDetail() {
|
|||||||
const [snapshotScheduleDraft, setSnapshotScheduleDraft] = useState({ intervalHours: 24, time: '03:00' })
|
const [snapshotScheduleDraft, setSnapshotScheduleDraft] = useState({ intervalHours: 24, time: '03:00' })
|
||||||
const [showFirewall, setShowFirewall] = useState(false)
|
const [showFirewall, setShowFirewall] = useState(false)
|
||||||
const [firewallEnabled, setFirewallEnabled] = useState(false)
|
const [firewallEnabled, setFirewallEnabled] = useState(false)
|
||||||
|
const [firewallDefaultAction, setFirewallDefaultAction] = useState<'ACCEPT' | 'DROP'>('DROP')
|
||||||
const [firewallRules, setFirewallRules] = useState<FirewallRule[]>([])
|
const [firewallRules, setFirewallRules] = useState<FirewallRule[]>([])
|
||||||
const [firewallSaving, setFirewallSaving] = useState(false)
|
const [firewallSaving, setFirewallSaving] = useState(false)
|
||||||
|
const [firewallMessage, setFirewallMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
|
||||||
const [editingFirewallRule, setEditingFirewallRule] = useState<FirewallRule | null>(null)
|
const [editingFirewallRule, setEditingFirewallRule] = useState<FirewallRule | null>(null)
|
||||||
const [showFirewallEditor, setShowFirewallEditor] = useState(false)
|
const [showFirewallEditor, setShowFirewallEditor] = useState(false)
|
||||||
|
|
||||||
@@ -417,29 +420,59 @@ export default function ContainerDetail() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const openFirewall = () => {
|
const syncFirewallState = (enabled: boolean, defaultAction: 'ACCEPT' | 'DROP', rules: FirewallRule[]) => {
|
||||||
|
const nextRules = rules.map(r => ({ ...r }))
|
||||||
|
setFirewallEnabled(enabled)
|
||||||
|
setFirewallDefaultAction(defaultAction)
|
||||||
|
setFirewallRules(nextRules)
|
||||||
|
setContainer(prev => prev ? {
|
||||||
|
...prev,
|
||||||
|
firewall_enabled: enabled,
|
||||||
|
firewall_default_action: defaultAction,
|
||||||
|
firewall_rules: nextRules.map(r => ({ ...r })),
|
||||||
|
} : prev)
|
||||||
|
}
|
||||||
|
|
||||||
|
const openFirewall = async () => {
|
||||||
if (!container) return
|
if (!container) return
|
||||||
setFirewallEnabled(container.firewall_enabled || false)
|
syncFirewallState(container.firewall_enabled || false, container.firewall_default_action || 'DROP', container.firewall_rules || [])
|
||||||
setFirewallRules(container.firewall_rules ? [...container.firewall_rules.map(r => ({ ...r }))] : [])
|
setFirewallMessage(null)
|
||||||
setShowFirewall(true)
|
setShowFirewall(true)
|
||||||
|
try {
|
||||||
|
const res = await getFirewall(container.id)
|
||||||
|
const data = res.data.data
|
||||||
|
if (data) syncFirewallState(data.enabled, data.default_action || 'DROP', data.rules || [])
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load firewall:', err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const saveFirewall = async () => {
|
const saveFirewall = async () => {
|
||||||
if (!container) return
|
if (!container) return
|
||||||
setFirewallSaving(true)
|
setFirewallSaving(true)
|
||||||
try {
|
try {
|
||||||
await updateFirewall(container.id, { enabled: firewallEnabled, rules: firewallRules })
|
const res = await updateFirewall(container.id, { enabled: firewallEnabled, default_action: firewallDefaultAction, rules: firewallRules })
|
||||||
|
const data = res.data.data
|
||||||
|
if (data) {
|
||||||
|
syncFirewallState(data.enabled, data.default_action || 'DROP', data.rules || [])
|
||||||
|
}
|
||||||
|
setFirewallMessage({ type: 'success', text: '防火墙设置已保存并应用' })
|
||||||
fetchContainer()
|
fetchContainer()
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
dialog.alert('错误', err?.response?.data?.message || '保存防火墙设置失败')
|
const message = err?.response?.data?.message || '保存防火墙设置失败'
|
||||||
|
setFirewallMessage({ type: 'error', text: message })
|
||||||
|
dialog.alert('错误', message)
|
||||||
} finally {
|
} finally {
|
||||||
setFirewallSaving(false)
|
setFirewallSaving(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const addFirewallRule = () => {
|
const addFirewallRule = () => {
|
||||||
|
const hasIPv4Firewall = (container?.public_ipv4s?.length || 0) > 0 || Math.max(container?.port_mapping_limit || 0, container?.port_mappings?.length || 0) > 0
|
||||||
|
const hasIPv6Firewall = !!container?.ipv6 || (container?.ipv6_addresses?.length || 0) > 0
|
||||||
setEditingFirewallRule({
|
setEditingFirewallRule({
|
||||||
id: '',
|
id: '',
|
||||||
|
network: hasIPv4Firewall ? 'ipv4' : hasIPv6Firewall ? 'ipv6' : 'ipv4',
|
||||||
direction: 'in',
|
direction: 'in',
|
||||||
protocol: 'tcp',
|
protocol: 'tcp',
|
||||||
port: '',
|
port: '',
|
||||||
@@ -876,6 +909,20 @@ export default function ContainerDetail() {
|
|||||||
const mappingLimit = Math.max(container.port_mapping_limit || 0, mappingCount)
|
const mappingLimit = Math.max(container.port_mapping_limit || 0, mappingCount)
|
||||||
const hasNATQuota = mappingLimit > 0
|
const hasNATQuota = mappingLimit > 0
|
||||||
const canAddMapping = hasNATQuota && mappingCount < mappingLimit && !isSubUserPolicyBlocked
|
const canAddMapping = hasNATQuota && mappingCount < mappingLimit && !isSubUserPolicyBlocked
|
||||||
|
const hasFirewallIPv4 = hasIndependentIPv4 || hasNATQuota
|
||||||
|
const firewallNetworkOptions: Array<{ value: NonNullable<FirewallRule['network']>; label: string }> = []
|
||||||
|
if (hasFirewallIPv4) {
|
||||||
|
firewallNetworkOptions.push({
|
||||||
|
value: 'ipv4',
|
||||||
|
label: hasIndependentIPv4 ? 'IPv4(公网 IPv4)' : 'IPv4(NAT)',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (hasIndependentIPv6) {
|
||||||
|
firewallNetworkOptions.push({ value: 'ipv6', label: 'IPv6' })
|
||||||
|
}
|
||||||
|
if (hasFirewallIPv4 && hasIndependentIPv6) {
|
||||||
|
firewallNetworkOptions.push({ value: 'all', label: '全部网络' })
|
||||||
|
}
|
||||||
const managementUrl = subUser?.access_code
|
const managementUrl = subUser?.access_code
|
||||||
? `${window.location.origin}/login?code=${encodeURIComponent(subUser.access_code)}`
|
? `${window.location.origin}/login?code=${encodeURIComponent(subUser.access_code)}`
|
||||||
: ''
|
: ''
|
||||||
@@ -997,7 +1044,7 @@ export default function ContainerDetail() {
|
|||||||
IPv4 NAT 管理
|
IPv4 NAT 管理
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
)}
|
)}
|
||||||
<ActionButton onClick={() => setShowFirewall(true)} disabled={isSubUserPolicyBlocked}>
|
<ActionButton onClick={openFirewall} disabled={isSubUserPolicyBlocked}>
|
||||||
<FirewallIcon className="w-3.5 h-3.5" />
|
<FirewallIcon className="w-3.5 h-3.5" />
|
||||||
防火墙
|
防火墙
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
@@ -1511,7 +1558,12 @@ export default function ContainerDetail() {
|
|||||||
{showFirewall && (
|
{showFirewall && (
|
||||||
<Modal title="防火墙设置" onClose={() => { setShowFirewall(false); setShowFirewallEditor(false); setEditingFirewallRule(null) }} wide extra={
|
<Modal title="防火墙设置" onClose={() => { setShowFirewall(false); setShowFirewallEditor(false); setEditingFirewallRule(null) }} wide extra={
|
||||||
!isSubUser && (
|
!isSubUser && (
|
||||||
<button onClick={addFirewallRule} className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-black text-white rounded-md text-xs hover:bg-gray-800">
|
<button
|
||||||
|
onClick={addFirewallRule}
|
||||||
|
disabled={firewallNetworkOptions.length === 0}
|
||||||
|
title={firewallNetworkOptions.length === 0 ? '当前容器没有可配置的 NAT、公网 IPv4 或 IPv6' : undefined}
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-black text-white rounded-md text-xs hover:bg-gray-800 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
<Plus className="w-3.5 h-3.5" />添加规则
|
<Plus className="w-3.5 h-3.5" />添加规则
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
@@ -1521,7 +1573,11 @@ export default function ContainerDetail() {
|
|||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-medium text-gray-800">防火墙</div>
|
<div className="text-sm font-medium text-gray-800">防火墙</div>
|
||||||
<div className="text-xs text-gray-500">启用后默认拒绝所有入站和出站流量,仅放行下方规则</div>
|
<div className="text-xs text-gray-500">
|
||||||
|
{firewallEnabled
|
||||||
|
? (firewallDefaultAction === 'DROP' ? '已启用,未匹配规则的流量将被拒绝' : '已启用,未匹配规则的流量将被放行')
|
||||||
|
: '未启用时不接管该容器流量'}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => setFirewallEnabled(!firewallEnabled)}
|
onClick={() => setFirewallEnabled(!firewallEnabled)}
|
||||||
@@ -1531,12 +1587,47 @@ export default function ContainerDetail() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between gap-4 rounded-md border border-gray-200 px-3 py-2">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-gray-800">默认动作</div>
|
||||||
|
<div className="text-xs text-gray-500">没有命中下方规则时如何处理</div>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
value={firewallDefaultAction}
|
||||||
|
onChange={(e) => setFirewallDefaultAction(e.target.value as 'ACCEPT' | 'DROP')}
|
||||||
|
disabled={isSubUser}
|
||||||
|
className="rounded-md border border-gray-300 bg-white px-2.5 py-1.5 text-xs text-gray-800 focus:border-black focus:outline-none focus:ring-2 focus:ring-black disabled:opacity-60"
|
||||||
|
>
|
||||||
|
<option value="DROP">未匹配拒绝</option>
|
||||||
|
<option value="ACCEPT">未匹配放行</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-md border border-blue-100 bg-blue-50 px-3 py-2 text-xs text-blue-800">
|
||||||
|
<div className="font-medium text-blue-900">网络范围</div>
|
||||||
|
<div className="mt-1">
|
||||||
|
{firewallNetworkOptions.length > 0
|
||||||
|
? `可配置:${firewallNetworkOptions.filter((option) => option.value !== 'all').map((option) => option.label).join('、')}。`
|
||||||
|
: '当前容器未分配 IPv4 NAT、独立公网 IPv4 或 IPv6,暂无可配置网络。'}
|
||||||
|
{hasFirewallIPv4 ? ` IPv4 规则覆盖${hasIndependentIPv4 ? '独立公网 IPv4' : 'IPv4 NAT 端口映射'}。` : ''}
|
||||||
|
{hasNATQuota && !hasIndependentIPv4 ? ' NAT 入站端口按容器内部端口匹配,不是宿主机公网端口。' : ''}
|
||||||
|
{hasIndependentIPv6 ? ' IPv6 规则覆盖该容器已分配的 IPv6 地址。' : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{firewallMessage && (
|
||||||
|
<div className={`rounded-md px-3 py-2 text-xs ${firewallMessage.type === 'success' ? 'border border-emerald-100 bg-emerald-50 text-emerald-700' : 'border border-red-100 bg-red-50 text-red-700'}`}>
|
||||||
|
{firewallMessage.text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Rules table */}
|
{/* Rules table */}
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="border-b border-gray-200 bg-gray-50 text-xs text-gray-500">
|
<thead className="border-b border-gray-200 bg-gray-50 text-xs text-gray-500">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-3 py-2 text-left font-medium">状态</th>
|
<th className="px-3 py-2 text-left font-medium">状态</th>
|
||||||
|
<th className="px-3 py-2 text-left font-medium">网络</th>
|
||||||
<th className="px-3 py-2 text-left font-medium">方向</th>
|
<th className="px-3 py-2 text-left font-medium">方向</th>
|
||||||
<th className="px-3 py-2 text-left font-medium">协议</th>
|
<th className="px-3 py-2 text-left font-medium">协议</th>
|
||||||
<th className="px-3 py-2 text-left font-medium">端口</th>
|
<th className="px-3 py-2 text-left font-medium">端口</th>
|
||||||
@@ -1554,6 +1645,11 @@ export default function ContainerDetail() {
|
|||||||
<span className={`inline-block h-3 w-3 transform rounded-full bg-white transition-transform ${rule.enabled ? 'translate-x-3.5' : 'translate-x-0.5'}`} />
|
<span className={`inline-block h-3 w-3 transform rounded-full bg-white transition-transform ${rule.enabled ? 'translate-x-3.5' : 'translate-x-0.5'}`} />
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<span className="inline-flex rounded bg-gray-100 px-1.5 py-0.5 text-xs font-medium text-gray-700">
|
||||||
|
{(rule.network || 'ipv4') === 'ipv6' ? 'IPv6' : (rule.network || 'ipv4') === 'all' ? '全部' : 'IPv4'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
<td className="px-3 py-2">
|
<td className="px-3 py-2">
|
||||||
<span className={`inline-flex px-1.5 py-0.5 rounded text-xs font-medium ${rule.direction === 'in' ? 'bg-blue-50 text-blue-700' : 'bg-orange-50 text-orange-700'}`}>
|
<span className={`inline-flex px-1.5 py-0.5 rounded text-xs font-medium ${rule.direction === 'in' ? 'bg-blue-50 text-blue-700' : 'bg-orange-50 text-orange-700'}`}>
|
||||||
{rule.direction === 'in' ? '入站' : '出站'}
|
{rule.direction === 'in' ? '入站' : '出站'}
|
||||||
@@ -1571,7 +1667,14 @@ export default function ContainerDetail() {
|
|||||||
{!isSubUser && (
|
{!isSubUser && (
|
||||||
<td className="px-3 py-2 text-right">
|
<td className="px-3 py-2 text-right">
|
||||||
<div className="inline-flex items-center gap-1">
|
<div className="inline-flex items-center gap-1">
|
||||||
<button onClick={() => { setEditingFirewallRule({ ...rule }); setShowFirewallEditor(true) }} className="p-1.5 text-gray-400 hover:text-gray-700 rounded hover:bg-gray-100">
|
<button onClick={() => {
|
||||||
|
const currentNetwork = (rule.network || 'ipv4') as NonNullable<FirewallRule['network']>
|
||||||
|
const network = firewallNetworkOptions.some((option) => option.value === currentNetwork)
|
||||||
|
? currentNetwork
|
||||||
|
: (firewallNetworkOptions[0]?.value || currentNetwork)
|
||||||
|
setEditingFirewallRule({ ...rule, network })
|
||||||
|
setShowFirewallEditor(true)
|
||||||
|
}} className="p-1.5 text-gray-400 hover:text-gray-700 rounded hover:bg-gray-100">
|
||||||
<Pencil className="h-3.5 w-3.5" />
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => deleteFirewallRule(rule.id)} className="p-1.5 text-gray-400 hover:text-red-600 rounded hover:bg-red-50">
|
<button onClick={() => deleteFirewallRule(rule.id)} className="p-1.5 text-gray-400 hover:text-red-600 rounded hover:bg-red-50">
|
||||||
@@ -1583,7 +1686,7 @@ export default function ContainerDetail() {
|
|||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
{firewallRules.length === 0 && (
|
{firewallRules.length === 0 && (
|
||||||
<tr><td colSpan={isSubUser ? 7 : 8} className="px-3 py-6 text-center text-xs text-gray-400">暂无防火墙规则</td></tr>
|
<tr><td colSpan={isSubUser ? 8 : 9} className="px-3 py-6 text-center text-xs text-gray-400">暂无防火墙规则</td></tr>
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -1605,6 +1708,17 @@ export default function ContainerDetail() {
|
|||||||
{showFirewallEditor && editingFirewallRule && (
|
{showFirewallEditor && editingFirewallRule && (
|
||||||
<Modal title={editingFirewallRule.id ? '编辑规则' : '添加规则'} onClose={() => { setShowFirewallEditor(false); setEditingFirewallRule(null) }}>
|
<Modal title={editingFirewallRule.id ? '编辑规则' : '添加规则'} onClose={() => { setShowFirewallEditor(false); setEditingFirewallRule(null) }}>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
<Field label="网络">
|
||||||
|
{firewallNetworkOptions.length > 0 ? (
|
||||||
|
<select value={editingFirewallRule.network || firewallNetworkOptions[0].value} onChange={(e) => setEditingFirewallRule({ ...editingFirewallRule, network: e.target.value as FirewallRule['network'] })} className={inputClass}>
|
||||||
|
{firewallNetworkOptions.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>{option.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<input value="当前容器没有可配置网络" disabled className={`${inputClass} bg-gray-100 text-gray-400`} />
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
<Field label="方向">
|
<Field label="方向">
|
||||||
<select value={editingFirewallRule.direction} onChange={(e) => setEditingFirewallRule({ ...editingFirewallRule, direction: e.target.value as 'in' | 'out' })} className={inputClass}>
|
<select value={editingFirewallRule.direction} onChange={(e) => setEditingFirewallRule({ ...editingFirewallRule, direction: e.target.value as 'in' | 'out' })} className={inputClass}>
|
||||||
<option value="in">入站 (Inbound)</option>
|
<option value="in">入站 (Inbound)</option>
|
||||||
@@ -1612,18 +1726,52 @@ export default function ContainerDetail() {
|
|||||||
</select>
|
</select>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="协议">
|
<Field label="协议">
|
||||||
<select value={editingFirewallRule.protocol} onChange={(e) => setEditingFirewallRule({ ...editingFirewallRule, protocol: e.target.value as any })} className={inputClass}>
|
<select
|
||||||
|
value={editingFirewallRule.protocol}
|
||||||
|
onChange={(e) => {
|
||||||
|
const protocol = e.target.value as FirewallRule['protocol']
|
||||||
|
setEditingFirewallRule({
|
||||||
|
...editingFirewallRule,
|
||||||
|
protocol,
|
||||||
|
port: protocol === 'tcp' || protocol === 'udp' ? editingFirewallRule.port : '',
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
className={inputClass}
|
||||||
|
>
|
||||||
<option value="tcp">TCP</option>
|
<option value="tcp">TCP</option>
|
||||||
<option value="udp">UDP</option>
|
<option value="udp">UDP</option>
|
||||||
<option value="icmp">ICMP</option>
|
<option value="icmp">ICMP</option>
|
||||||
<option value="all">全部</option>
|
<option value="all">全部</option>
|
||||||
</select>
|
</select>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="端口" hint="留空为全部端口,支持: 22 | 80,443 | 8000-9000">
|
<Field
|
||||||
<input value={editingFirewallRule.port} onChange={(e) => setEditingFirewallRule({ ...editingFirewallRule, port: e.target.value })} placeholder="如: 22 或 80,443 或 8000-9000" className={inputClass} />
|
label="端口"
|
||||||
|
hint={editingFirewallRule.protocol === 'tcp' || editingFirewallRule.protocol === 'udp'
|
||||||
|
? (editingFirewallRule.direction === 'in'
|
||||||
|
? ((editingFirewallRule.network || 'ipv4') === 'ipv4' && hasNATQuota && !hasIndependentIPv4
|
||||||
|
? 'NAT 入站填容器内部端口,例如公网 22023 -> 容器 22,这里填 22'
|
||||||
|
: '入站填容器服务端口;留空为全部端口,支持: 22 | 80,443 | 8000-9000')
|
||||||
|
: '出站填远端目标端口;留空为全部端口,支持: 22 | 80,443 | 8000-9000')
|
||||||
|
: '端口仅适用于 TCP/UDP'}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
value={editingFirewallRule.port}
|
||||||
|
onChange={(e) => setEditingFirewallRule({ ...editingFirewallRule, port: e.target.value })}
|
||||||
|
placeholder={editingFirewallRule.protocol === 'tcp' || editingFirewallRule.protocol === 'udp' ? '如: 22 或 80,443 或 8000-9000' : '当前协议不使用端口'}
|
||||||
|
disabled={editingFirewallRule.protocol !== 'tcp' && editingFirewallRule.protocol !== 'udp'}
|
||||||
|
className={`${inputClass} disabled:bg-gray-100 disabled:text-gray-400`}
|
||||||
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label={editingFirewallRule.direction === 'in' ? '来源 IP' : '目标 IP'} hint="留空为任意 IP,支持 CIDR: 192.168.1.0/24">
|
<Field
|
||||||
<input value={editingFirewallRule.source_ip} onChange={(e) => setEditingFirewallRule({ ...editingFirewallRule, source_ip: e.target.value })} placeholder="如: 192.168.1.0/24" className={inputClass} />
|
label={editingFirewallRule.direction === 'in' ? '来源 IP' : '目标 IP'}
|
||||||
|
hint={(editingFirewallRule.network || 'ipv4') === 'ipv6' ? '留空为任意 IPv6,支持 CIDR: 2001:db8::/64' : (editingFirewallRule.network || 'ipv4') === 'all' ? '留空为任意 IP,支持 IPv4/IPv6 CIDR' : '留空为任意 IPv4,支持 CIDR: 192.168.1.0/24'}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
value={editingFirewallRule.source_ip}
|
||||||
|
onChange={(e) => setEditingFirewallRule({ ...editingFirewallRule, source_ip: e.target.value })}
|
||||||
|
placeholder={(editingFirewallRule.network || 'ipv4') === 'ipv6' ? '如: 2001:db8::/64' : (editingFirewallRule.network || 'ipv4') === 'all' ? '如: 192.168.1.0/24 或 2001:db8::/64' : '如: 192.168.1.0/24'}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="动作">
|
<Field label="动作">
|
||||||
<select value={editingFirewallRule.action} onChange={(e) => setEditingFirewallRule({ ...editingFirewallRule, action: e.target.value as 'ACCEPT' | 'DROP' })} className={inputClass}>
|
<select value={editingFirewallRule.action} onChange={(e) => setEditingFirewallRule({ ...editingFirewallRule, action: e.target.value as 'ACCEPT' | 'DROP' })} className={inputClass}>
|
||||||
|
|||||||
@@ -725,6 +725,7 @@ function toPlaceholder(cfg: CreateContainerRequest): DisplayContainer {
|
|||||||
port_mappings: [],
|
port_mappings: [],
|
||||||
port_mapping_limit: cfg.assign_nat === false ? 0 : (cfg.port_mapping_count || 0),
|
port_mapping_limit: cfg.assign_nat === false ? 0 : (cfg.port_mapping_count || 0),
|
||||||
firewall_enabled: false,
|
firewall_enabled: false,
|
||||||
|
firewall_default_action: 'DROP',
|
||||||
firewall_rules: [],
|
firewall_rules: [],
|
||||||
snapshot_limit: cfg.snapshot_limit || 3,
|
snapshot_limit: cfg.snapshot_limit || 3,
|
||||||
created_at: '',
|
created_at: '',
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ export interface PortMapping {
|
|||||||
|
|
||||||
export interface FirewallRule {
|
export interface FirewallRule {
|
||||||
id: string
|
id: string
|
||||||
|
network?: 'ipv4' | 'ipv6' | 'all'
|
||||||
direction: 'in' | 'out'
|
direction: 'in' | 'out'
|
||||||
protocol: 'tcp' | 'udp' | 'icmp' | 'all'
|
protocol: 'tcp' | 'udp' | 'icmp' | 'all'
|
||||||
port: string
|
port: string
|
||||||
@@ -100,6 +101,7 @@ export interface Container {
|
|||||||
port_mappings: PortMapping[]
|
port_mappings: PortMapping[]
|
||||||
port_mapping_limit: number
|
port_mapping_limit: number
|
||||||
firewall_enabled: boolean
|
firewall_enabled: boolean
|
||||||
|
firewall_default_action: 'ACCEPT' | 'DROP'
|
||||||
firewall_rules: FirewallRule[]
|
firewall_rules: FirewallRule[]
|
||||||
snapshot_limit: number
|
snapshot_limit: number
|
||||||
created_at: string
|
created_at: string
|
||||||
@@ -501,10 +503,10 @@ export const deletePortMapping = (id: ContainerIdentifier, index: number) =>
|
|||||||
api.delete<APIResponse<PortMapping[]>>(`/containers/${id}/port-mappings/${index}`)
|
api.delete<APIResponse<PortMapping[]>>(`/containers/${id}/port-mappings/${index}`)
|
||||||
|
|
||||||
export const getFirewall = (id: ContainerIdentifier) =>
|
export const getFirewall = (id: ContainerIdentifier) =>
|
||||||
api.get<APIResponse<{ enabled: boolean; rules: FirewallRule[] }>>(`/containers/${id}/firewall`)
|
api.get<APIResponse<{ enabled: boolean; default_action: 'ACCEPT' | 'DROP'; rules: FirewallRule[] }>>(`/containers/${id}/firewall`)
|
||||||
|
|
||||||
export const updateFirewall = (id: ContainerIdentifier, data: { enabled?: boolean; rules?: FirewallRule[] }) =>
|
export const updateFirewall = (id: ContainerIdentifier, data: { enabled?: boolean; default_action?: 'ACCEPT' | 'DROP'; rules?: FirewallRule[] }) =>
|
||||||
api.put<APIResponse<{ enabled: boolean; rules: FirewallRule[] }>>(`/containers/${id}/firewall`, data)
|
api.put<APIResponse<{ enabled: boolean; default_action: 'ACCEPT' | 'DROP'; rules: FirewallRule[] }>>(`/containers/${id}/firewall`, data)
|
||||||
|
|
||||||
export const updateContainerExpiry = (id: ContainerIdentifier, expiresAt: string) =>
|
export const updateContainerExpiry = (id: ContainerIdentifier, expiresAt: string) =>
|
||||||
api.put<APIResponse>(`/containers/${id}/expiry`, { expires_at: expiresAt })
|
api.put<APIResponse>(`/containers/${id}/expiry`, { expires_at: expiresAt })
|
||||||
|
|||||||
Reference in New Issue
Block a user