mirror of
https://github.com/MengMengCode/CLICD.git
synced 2026-08-05 05:36:07 +08:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fbc539ea47 | |||
| 875cd4716b | |||
| 6194b6e364 | |||
| 30d6b2d9f7 | |||
| 2f94498df2 |
@@ -90,7 +90,7 @@ func hasScope(r *http.Request, scope string) bool {
|
||||
|
||||
func subUserScopeAllowed(scope string) bool {
|
||||
switch scope {
|
||||
case "container:read", "container:power", "container:reinstall", "container:network",
|
||||
case "container:read", "container:power", "container:reinstall", "container:password", "container:network",
|
||||
"dashboard:read", "image:read", "task:read", "snapshot:read", "snapshot:create", "snapshot:delete", "snapshot:restore", "snapshot:schedule",
|
||||
"terminal:ssh", "terminal:vnc":
|
||||
return true
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"clicd/internal/config"
|
||||
"clicd/internal/lxc"
|
||||
)
|
||||
|
||||
func generateFirewallRuleID() string {
|
||||
const chars = "abcdefghijklmnopqrstuvwxyz0123456789"
|
||||
b := make([]byte, 8)
|
||||
for i := range b {
|
||||
b[i] = chars[rand.Intn(len(chars))]
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func getFirewall(w http.ResponseWriter, r *http.Request, id int) {
|
||||
c := config.FindContainer(id)
|
||||
if c == nil {
|
||||
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"})
|
||||
return
|
||||
}
|
||||
jsonResponse(w, http.StatusOK, APIResponse{
|
||||
Success: true,
|
||||
Data: map[string]interface{}{
|
||||
"enabled": c.FirewallEnabled,
|
||||
"rules": c.FirewallRules,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func updateFirewall(w http.ResponseWriter, r *http.Request, id int) {
|
||||
c := config.FindContainer(id)
|
||||
if c == nil {
|
||||
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Enabled *bool `json:"enabled"`
|
||||
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
|
||||
}
|
||||
|
||||
if req.Enabled != nil {
|
||||
c.FirewallEnabled = *req.Enabled
|
||||
}
|
||||
if req.Rules != nil {
|
||||
// Validate and assign IDs to new rules
|
||||
rules := *req.Rules
|
||||
for i := range rules {
|
||||
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].SourceIP = strings.TrimSpace(rules[i].SourceIP)
|
||||
rules[i].Port = strings.TrimSpace(rules[i].Port)
|
||||
|
||||
if rules[i].Direction != "in" && rules[i].Direction != "out" {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid direction: " + rules[i].Direction})
|
||||
return
|
||||
}
|
||||
if rules[i].Protocol != "tcp" && rules[i].Protocol != "udp" && rules[i].Protocol != "icmp" && rules[i].Protocol != "all" {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid protocol: " + rules[i].Protocol})
|
||||
return
|
||||
}
|
||||
if rules[i].Action != "ACCEPT" && rules[i].Action != "DROP" {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid action: " + rules[i].Action})
|
||||
return
|
||||
}
|
||||
if rules[i].ID == "" {
|
||||
rules[i].ID = generateFirewallRuleID()
|
||||
}
|
||||
// Validate port spec
|
||||
if rules[i].Port != "" {
|
||||
if err := validatePortSpec(rules[i].Port); err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid port: " + err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
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 {
|
||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to apply firewall rules: " + err.Error()})
|
||||
return
|
||||
}
|
||||
} else if !c.FirewallEnabled {
|
||||
// If disabled and not running, clean any lingering rules
|
||||
lxc.CleanFirewallRules(id)
|
||||
}
|
||||
|
||||
jsonResponse(w, http.StatusOK, APIResponse{
|
||||
Success: true,
|
||||
Message: "Firewall updated",
|
||||
Data: map[string]interface{}{
|
||||
"enabled": c.FirewallEnabled,
|
||||
"rules": c.FirewallRules,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func validatePortSpec(port string) error {
|
||||
port = strings.TrimSpace(port)
|
||||
if port == "" {
|
||||
return nil
|
||||
}
|
||||
// Support: "22", "80,443", "8000-9000", "80,443,8000-9000"
|
||||
for _, part := range strings.Split(port, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(part, "-") {
|
||||
// Range
|
||||
bounds := strings.SplitN(part, "-", 2)
|
||||
lo, err := strconv.Atoi(strings.TrimSpace(bounds[0]))
|
||||
if err != nil || lo < 1 || lo > 65535 {
|
||||
return &portValidationError{part}
|
||||
}
|
||||
hi, err := strconv.Atoi(strings.TrimSpace(bounds[1]))
|
||||
if err != nil || hi < 1 || hi > 65535 {
|
||||
return &portValidationError{part}
|
||||
}
|
||||
} else {
|
||||
p, err := strconv.Atoi(part)
|
||||
if err != nil || p < 1 || p > 65535 {
|
||||
return &portValidationError{part}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type portValidationError struct {
|
||||
port string
|
||||
}
|
||||
|
||||
func (e *portValidationError) Error() string {
|
||||
return "invalid port value: " + e.port
|
||||
}
|
||||
@@ -182,6 +182,16 @@ func HandleSingleContainer(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
deletePortMapping(w, r, id, strings.TrimPrefix(action, "port-mappings/"))
|
||||
case action == "firewall" && r.Method == http.MethodGet:
|
||||
if !requireScope(w, r, "container:network") {
|
||||
return
|
||||
}
|
||||
getFirewall(w, r, id)
|
||||
case action == "firewall" && r.Method == http.MethodPut:
|
||||
if !requireScope(w, r, "container:network") {
|
||||
return
|
||||
}
|
||||
updateFirewall(w, r, id)
|
||||
case r.Method == http.MethodGet:
|
||||
if !requireScope(w, r, "container:read") {
|
||||
return
|
||||
|
||||
@@ -373,6 +373,15 @@ func SubUserMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
imagesEnabledPath := "/api/images/enabled"
|
||||
if strings.HasPrefix(path, "/api/v1/") {
|
||||
imagesEnabledPath = "/api/v1/images/enabled"
|
||||
}
|
||||
if path == imagesEnabledPath && r.Method == http.MethodGet {
|
||||
next(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if path == containerListPath {
|
||||
if r.Method != http.MethodGet {
|
||||
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Sub-users cannot create containers"})
|
||||
@@ -503,7 +512,7 @@ func isSubUserContainerActionAllowed(action string, method string) bool {
|
||||
return method == http.MethodPost
|
||||
case strings.HasPrefix(action, "snapshots/"):
|
||||
return method == http.MethodDelete || method == http.MethodPost
|
||||
case action == "start" || action == "stop" || action == "restart" || action == "reinstall":
|
||||
case action == "start" || action == "stop" || action == "restart" || action == "reinstall" || action == "reset-password":
|
||||
return method == http.MethodPost
|
||||
case strings.HasPrefix(action, "port-mappings/"):
|
||||
return method == http.MethodPut
|
||||
|
||||
@@ -22,6 +22,17 @@ type PortMapping struct {
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
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"
|
||||
Description string `json:"description"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
type PublicIPv4Assignment struct {
|
||||
Address string `json:"address"`
|
||||
Interface string `json:"interface,omitempty"`
|
||||
@@ -124,6 +135,8 @@ type Container struct {
|
||||
SSHHostKey string `json:"ssh_host_key,omitempty"`
|
||||
PortMappings []PortMapping `json:"port_mappings"`
|
||||
PortMappingLimit int `json:"port_mapping_limit"`
|
||||
FirewallEnabled bool `json:"firewall_enabled"`
|
||||
FirewallRules []FirewallRule `json:"firewall_rules"`
|
||||
SnapshotLimit int `json:"snapshot_limit"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
|
||||
@@ -364,6 +364,8 @@ func ensureSchemaMigrations() error {
|
||||
{"port_mappings", "host_ip", "TEXT"},
|
||||
{"container_public_ipv4s", "prefix_len", "INTEGER"},
|
||||
{"container_public_ipv4s", "gateway", "TEXT"},
|
||||
{"containers", "firewall_enabled", "INTEGER NOT NULL DEFAULT 0"},
|
||||
{"containers", "firewall_rules", "TEXT"},
|
||||
} {
|
||||
if err := ensureColumn(column.table, column.name, column.def); err != nil {
|
||||
return err
|
||||
@@ -583,8 +585,9 @@ func saveContainers(tx *sql.Tx) error {
|
||||
ssh_host_key, port_mapping_limit, snapshot_limit, created_at, expires_at,
|
||||
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
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
policy_blocked, policy_blocked_reason, policy_blocked_at,
|
||||
firewall_enabled, 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,
|
||||
@@ -593,6 +596,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),
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -789,7 +793,8 @@ func loadContainers() ([]Container, error) {
|
||||
ssh_host_key, port_mapping_limit, snapshot_limit, created_at, expires_at,
|
||||
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
|
||||
policy_blocked, policy_blocked_reason, policy_blocked_at,
|
||||
firewall_enabled, firewall_rules
|
||||
FROM containers ORDER BY id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -799,7 +804,8 @@ func loadContainers() ([]Container, error) {
|
||||
result := []Container{}
|
||||
for rows.Next() {
|
||||
var c Container
|
||||
var scheduleEnabled, policyBlocked int
|
||||
var scheduleEnabled, policyBlocked, firewallEnabled int
|
||||
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,
|
||||
&c.VCPU, &c.RAMMB, &c.DiskGB, &c.NetworkBWMbps, &c.MonthlyTrafficGB, &c.TrafficMode, &c.TrafficInGB,
|
||||
@@ -809,11 +815,16 @@ func loadContainers() ([]Container, error) {
|
||||
&scheduleEnabled, &c.SnapshotScheduleIntervalHours, &c.SnapshotScheduleTime,
|
||||
&c.SnapshotScheduleLastRun, &c.SnapshotScheduleNextRun, &c.SnapshotScheduleCreatedBy,
|
||||
&policyBlocked, &c.PolicyBlockedReason, &c.PolicyBlockedAt,
|
||||
&firewallEnabled, &firewallRulesJSON,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.SnapshotScheduleEnabled = scheduleEnabled != 0
|
||||
c.PolicyBlocked = policyBlocked != 0
|
||||
c.FirewallEnabled = firewallEnabled != 0
|
||||
if firewallRulesJSON.Valid && strings.TrimSpace(firewallRulesJSON.String) != "" {
|
||||
_ = json.Unmarshal([]byte(firewallRulesJSON.String), &c.FirewallRules)
|
||||
}
|
||||
result = append(result, c)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
@@ -1167,6 +1178,17 @@ func boolInt(value bool) int {
|
||||
return 0
|
||||
}
|
||||
|
||||
func marshalFirewallRules(rules []FirewallRule) interface{} {
|
||||
if len(rules) == 0 {
|
||||
return nil
|
||||
}
|
||||
data, err := json.Marshal(rules)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func boolPtrInt(value *bool) interface{} {
|
||||
if value == nil {
|
||||
return nil
|
||||
|
||||
@@ -84,6 +84,7 @@ var (
|
||||
lastTrafficSnapshot = map[string]trafficSample{}
|
||||
kvmSnapshotMu sync.Mutex
|
||||
kvmSSHEnsureLocks sync.Map
|
||||
knownSSHHostKeys sync.Map // TOFU host key store: host:port → ssh.PublicKey
|
||||
portMapApplyMu sync.Mutex
|
||||
lastPortMapApply = map[int]time.Time{}
|
||||
windowsMetricsMu sync.Mutex
|
||||
@@ -432,7 +433,8 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig
|
||||
}
|
||||
ipv6List := configIPv6AssignmentAddresses(ipv6Assignments)
|
||||
ipv4List := configIPv4AssignmentAddresses(publicIPv4s)
|
||||
defaultHostIP := lxc.DefaultPortMappingHostIP(publicIPv4s)
|
||||
// NAT4 port mappings should bind to the host IP, not the VM's independent public IPv4.
|
||||
defaultHostIP := ""
|
||||
|
||||
var xml string
|
||||
winAdminPassword := ""
|
||||
@@ -624,6 +626,9 @@ func (m *Manager) StartContainer(id int) error {
|
||||
if err := lxc.NewManager().ApplyPortMappings(id); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := lxc.ApplyFirewallRules(id); err != nil {
|
||||
fmt.Printf("Warning: failed to apply firewall rules: %v\n", err)
|
||||
}
|
||||
}
|
||||
// Wait for cloud-init to finish and SSH to be reachable (password-only mode)
|
||||
if !isWindows && c.IP != "" {
|
||||
@@ -641,6 +646,20 @@ func (m *Manager) StartContainer(id int) error {
|
||||
}
|
||||
|
||||
// waitForCloudInitReady waits for cloud-init to finish and SSH to be reachable.
|
||||
// tofuHostKeyCallback implements Trust-On-First-Use host key verification.
|
||||
// On the first connection to a host, the key is accepted and remembered.
|
||||
// Subsequent connections must present the same key or the connection is rejected.
|
||||
func tofuHostKeyCallback(hostname string, remote net.Addr, key ssh.PublicKey) error {
|
||||
if stored, ok := knownSSHHostKeys.Load(hostname); ok {
|
||||
if bytes.Equal(stored.(ssh.PublicKey).Marshal(), key.Marshal()) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("host key mismatch for %s (possible MitM attack)", hostname)
|
||||
}
|
||||
knownSSHHostKeys.Store(hostname, key)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) waitForCloudInitReady(vmName, ip, password string) {
|
||||
if ip == "" || password == "" {
|
||||
return
|
||||
@@ -654,7 +673,7 @@ func (m *Manager) waitForCloudInitReady(vmName, ip, password string) {
|
||||
client, err := ssh.Dial("tcp", target, &ssh.ClientConfig{
|
||||
User: "root",
|
||||
Auth: []ssh.AuthMethod{ssh.Password(password)},
|
||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
||||
HostKeyCallback: tofuHostKeyCallback,
|
||||
Timeout: 5 * time.Second,
|
||||
})
|
||||
if err == nil {
|
||||
@@ -690,6 +709,7 @@ func (m *Manager) StopContainer(id int) error {
|
||||
return fmt.Errorf("container not found: %d", id)
|
||||
}
|
||||
_ = lxc.NewManager().CleanPortMappings(id)
|
||||
lxc.CleanFirewallRules(id)
|
||||
name := c.VirshName()
|
||||
status, _ := m.GetContainerStatus(name)
|
||||
if status != "running" {
|
||||
@@ -1167,6 +1187,7 @@ func (m *Manager) prepareVMForColdCopy(id int, name string) (bool, error) {
|
||||
time.Sleep(time.Second)
|
||||
} else {
|
||||
_ = lxc.NewManager().CleanPortMappings(id)
|
||||
lxc.CleanFirewallRules(id)
|
||||
}
|
||||
return wasRunning, nil
|
||||
}
|
||||
@@ -1850,8 +1871,7 @@ func windowsIPv6PowerShell(ipv6s []string) string {
|
||||
}
|
||||
return strings.Join([]string{
|
||||
"$clicdIPv6=@(" + strings.Join(quoted, ",") + ")",
|
||||
"$iface=$null",
|
||||
"for ($i=0; $i -lt 60 -and -not $iface; $i++) { $iface=Get-NetAdapter | Where-Object { $_.Status -eq 'Up' -and $_.HardwareInterface } | Sort-Object ifIndex | Select-Object -First 1; if (-not $iface) { Start-Sleep -Seconds 5 } }",
|
||||
// Reuse $iface already found by the main script
|
||||
"if ($iface) {",
|
||||
" foreach ($ip in $clicdIPv6) {",
|
||||
" Get-NetIPAddress -InterfaceIndex $iface.ifIndex -AddressFamily IPv6 -ErrorAction SilentlyContinue | Where-Object { $_.IPAddress -eq $ip } | Remove-NetIPAddress -Confirm:$false -ErrorAction SilentlyContinue",
|
||||
@@ -1875,8 +1895,7 @@ func windowsIPv4PowerShell(ipv4s []string) string {
|
||||
}
|
||||
return strings.Join([]string{
|
||||
"$clicdIPv4=@(" + strings.Join(quoted, ",") + ")",
|
||||
"$iface=$null",
|
||||
"for ($i=0; $i -lt 60 -and -not $iface; $i++) { $iface=Get-NetAdapter | Where-Object { $_.Status -eq 'Up' -and $_.HardwareInterface } | Sort-Object ifIndex | Select-Object -First 1; if (-not $iface) { Start-Sleep -Seconds 5 } }",
|
||||
// Reuse $iface already found by the main script
|
||||
"if ($iface) {",
|
||||
" foreach ($ip in $clicdIPv4) {",
|
||||
" Get-NetIPAddress -InterfaceIndex $iface.ifIndex -AddressFamily IPv4 -ErrorAction SilentlyContinue | Where-Object { $_.IPAddress -eq $ip } | Remove-NetIPAddress -Confirm:$false -ErrorAction SilentlyContinue",
|
||||
@@ -2472,6 +2491,9 @@ func (m *Manager) EnsureSSH(id int) error {
|
||||
if mapErr := lxc.NewManager().ApplyPortMappings(id); mapErr != nil {
|
||||
return mapErr
|
||||
}
|
||||
if err := lxc.ApplyFirewallRules(id); err != nil {
|
||||
fmt.Printf("Warning: failed to apply firewall rules: %v\n", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if lastErr == nil {
|
||||
|
||||
@@ -345,12 +345,7 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
|
||||
|
||||
// Setup default port mappings (SSH only)
|
||||
portMappings = SetupDefaultPortMappings(sshPort)
|
||||
defaultHostIP := defaultPortMappingHostIP(publicIPv4s)
|
||||
if defaultHostIP != "" {
|
||||
for i := range portMappings {
|
||||
portMappings[i].HostIP = defaultHostIP
|
||||
}
|
||||
}
|
||||
// NAT4 port mappings should bind to the host IP, not the container's independent public IPv4.
|
||||
tempC := &config.Container{ID: id, PublicIPv4s: publicIPv4s, PortMappings: portMappings}
|
||||
|
||||
extraPorts := cfg.ExtraPorts
|
||||
@@ -364,7 +359,7 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
|
||||
pm, err := normalizePortMapping(tempC, -1, config.PortMapping{
|
||||
ContainerPort: containerPort,
|
||||
HostPort: containerPort,
|
||||
HostIP: defaultHostIP,
|
||||
HostIP: "",
|
||||
Protocol: "tcp",
|
||||
Description: fmt.Sprintf("Port-%d", containerPort),
|
||||
})
|
||||
@@ -1329,6 +1324,9 @@ func (m *Manager) StartContainer(id int) error {
|
||||
if err := m.ApplyPortMappings(id); err != nil {
|
||||
fmt.Printf("Warning: failed to apply port mappings: %v\n", err)
|
||||
}
|
||||
if err := ApplyFirewallRules(id); err != nil {
|
||||
fmt.Printf("Warning: failed to apply firewall rules: %v\n", err)
|
||||
}
|
||||
if c.IPv6 != "" || len(c.IPv6Addresses) > 0 {
|
||||
if err := m.ApplyIPv6(id); err != nil {
|
||||
fmt.Printf("Warning: failed to apply IPv6 routing for %s: %v\n", lxcName, err)
|
||||
@@ -1477,11 +1475,13 @@ func (m *Manager) StopContainer(id int) error {
|
||||
if status != "running" {
|
||||
config.UpdateContainerStatus(id, "stopped")
|
||||
m.CleanPortMappings(id)
|
||||
CleanFirewallRules(id)
|
||||
m.cleanupBandwidthLimit(lxcName)
|
||||
return nil
|
||||
}
|
||||
|
||||
m.CleanPortMappings(id)
|
||||
CleanFirewallRules(id)
|
||||
m.cleanupBandwidthLimit(lxcName)
|
||||
|
||||
cmd := exec.Command("lxc-stop", "-n", lxcName)
|
||||
@@ -2590,6 +2590,7 @@ func (m *Manager) ReinstallContainer(id int, templateID string, authConfig ...Co
|
||||
|
||||
// Clean port mappings temporarily
|
||||
m.CleanPortMappings(id)
|
||||
CleanFirewallRules(id)
|
||||
|
||||
// Download the new OS into a temporary container, then replace only the
|
||||
// existing rootfs. The target container directory and config are preserved.
|
||||
|
||||
+184
-21
@@ -59,9 +59,9 @@ func (m *Manager) ApplyPortMappings(id int) error {
|
||||
}
|
||||
}
|
||||
|
||||
// When container has public IPv4 but no port mappings (independent IP mode),
|
||||
// ensure inbound DNAT for standard service ports (SSH / RDP).
|
||||
if len(c.PortMappings) == 0 && len(c.PublicIPv4s) > 0 {
|
||||
// When container has public IPv4, apply full port passthrough DNAT so the
|
||||
// container owns all ports on its public IP (no NAT management needed).
|
||||
if len(c.PublicIPv4s) > 0 {
|
||||
ensureIndependentIPv4Ingress(c, tag)
|
||||
}
|
||||
|
||||
@@ -75,40 +75,30 @@ func ensureIndependentIPv4Ingress(c *config.Container, tag string) {
|
||||
return
|
||||
}
|
||||
|
||||
type svcPort struct {
|
||||
port int
|
||||
protocol string
|
||||
desc string
|
||||
}
|
||||
servicePorts := []svcPort{{port: 22, protocol: "tcp", desc: "SSH"}}
|
||||
if strings.Contains(strings.ToLower(c.Template), "windows") {
|
||||
servicePorts = []svcPort{{port: 3389, protocol: "tcp", desc: "RDP"}}
|
||||
}
|
||||
|
||||
for _, assignment := range c.PublicIPv4s {
|
||||
hostIP := strings.TrimSpace(assignment.Address)
|
||||
if hostIP == "" {
|
||||
continue
|
||||
}
|
||||
for _, svc := range servicePorts {
|
||||
// Full port passthrough: DNAT all TCP+UDP traffic on this public IP to the container.
|
||||
for _, proto := range []string{"tcp", "udp"} {
|
||||
args := []string{
|
||||
"-t", "nat",
|
||||
"-I", "PREROUTING", "1",
|
||||
"-d", hostIP,
|
||||
"-p", svc.protocol,
|
||||
"--dport", fmt.Sprintf("%d", svc.port),
|
||||
"-p", proto,
|
||||
"-j", "DNAT",
|
||||
"--to-destination", fmt.Sprintf("%s:%d", c.IP, svc.port),
|
||||
"-m", "comment", "--comment", fmt.Sprintf("clicd-%s-%s-%d", tag, natRuleIPTag(hostIP), svc.port),
|
||||
"--to-destination", c.IP,
|
||||
"-m", "comment", "--comment", fmt.Sprintf("clicd-%s-%s-all-%s", tag, natRuleIPTag(hostIP), proto),
|
||||
}
|
||||
cmd := exec.Command("iptables", args...)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: failed to apply %s ingress %s:%d->%s:%d: %v, output: %s\n",
|
||||
svc.desc, hostIP, svc.port, c.IP, svc.port, err, string(output))
|
||||
fmt.Printf("Warning: failed to apply %s passthrough %s->%s: %v, output: %s\n",
|
||||
proto, hostIP, c.IP, err, string(output))
|
||||
continue
|
||||
}
|
||||
fmt.Printf("%s ingress: %s:%d -> %s:%d\n", svc.desc, hostIP, svc.port, c.IP, svc.port)
|
||||
fmt.Printf("IPv4 passthrough (%s): %s -> %s (all ports)\n", proto, hostIP, c.IP)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -578,3 +568,176 @@ func hostPortKey(hostIP string, port int) int {
|
||||
}
|
||||
return port + (sum % 1000000 * 100000)
|
||||
}
|
||||
|
||||
// CleanFirewallRules removes all firewall rules for a container from the FORWARD chain.
|
||||
func CleanFirewallRules(id int) {
|
||||
tag := clicdTag(id)
|
||||
// Remove all rules with the firewall tag prefix
|
||||
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()
|
||||
|
||||
// Also remove legacy default policy rules (without specific rule ID)
|
||||
for _, suffix := range []string{"default-in", "default-out"} {
|
||||
for _, proto := range []string{"tcp", "udp"} {
|
||||
exec.Command("iptables", "-D", "FORWARD",
|
||||
"-m", "comment", "--comment", fmt.Sprintf("clicd-%s-fw-%s-%s", tag, suffix, proto),
|
||||
).CombinedOutput()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ApplyFirewallRules applies iptables FORWARD rules for a container's firewall configuration.
|
||||
func ApplyFirewallRules(id int) error {
|
||||
c := config.FindContainer(id)
|
||||
if c == nil {
|
||||
return fmt.Errorf("container not found: %d", id)
|
||||
}
|
||||
|
||||
// Always clean existing firewall rules first
|
||||
CleanFirewallRules(id)
|
||||
|
||||
// If firewall is disabled or no rules, nothing to apply
|
||||
if !c.FirewallEnabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
bridge := "lxcbr0"
|
||||
if c.IsKVM() {
|
||||
bridge = "virbr0"
|
||||
}
|
||||
containerIP := c.IP
|
||||
if containerIP == "" {
|
||||
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)
|
||||
|
||||
for _, rule := range c.FirewallRules {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
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)
|
||||
switch rule.Direction {
|
||||
case "in":
|
||||
args = append(args, "-i", bridge, "-d", containerIP+"/32")
|
||||
case "out":
|
||||
args = append(args, "-o", bridge, "-s", containerIP+"/32")
|
||||
default:
|
||||
return fmt.Errorf("invalid direction: %s", rule.Direction)
|
||||
}
|
||||
|
||||
// Protocol
|
||||
switch rule.Protocol {
|
||||
case "tcp", "udp":
|
||||
args = append(args, "-p", rule.Protocol)
|
||||
case "icmp":
|
||||
args = append(args, "-p", "icmp")
|
||||
case "all":
|
||||
// no protocol filter
|
||||
default:
|
||||
return fmt.Errorf("invalid protocol: %s", rule.Protocol)
|
||||
}
|
||||
|
||||
// Port matching (only for tcp/udp)
|
||||
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))
|
||||
}
|
||||
|
||||
// Source IP filter (for "out" direction, this matches the remote source; for "in", it matches the sender)
|
||||
if rule.SourceIP != "" {
|
||||
switch rule.Direction {
|
||||
case "in":
|
||||
args = append(args, "-s", rule.SourceIP)
|
||||
case "out":
|
||||
args = append(args, "-d", rule.SourceIP)
|
||||
}
|
||||
}
|
||||
|
||||
// Action
|
||||
action := "DROP"
|
||||
if rule.Action == "ACCEPT" {
|
||||
action = "ACCEPT"
|
||||
}
|
||||
args = append(args, "-j", action)
|
||||
|
||||
// Comment tag for cleanup
|
||||
args = append(args, "-m", "comment", "--comment", commentTag)
|
||||
|
||||
cmd := exec.Command("iptables", args...)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("iptables error: %s", string(output))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// normalizePortSpec converts user port input to iptables-compatible port spec.
|
||||
// "80,443" -> "80,443", "8000-9000" -> "8000:9000", "22" -> "22"
|
||||
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])
|
||||
}
|
||||
}
|
||||
return port
|
||||
}
|
||||
|
||||
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{
|
||||
"-I", "FORWARD", "1",
|
||||
"-o", bridge,
|
||||
"-s", containerIP + "/32",
|
||||
"-p", proto,
|
||||
"-j", "DROP",
|
||||
"-m", "comment", "--comment", fmt.Sprintf("clicd-%s-fw-default-out-%s", tag, proto),
|
||||
}
|
||||
cmd := exec.Command("iptables", args...)
|
||||
cmd.CombinedOutput()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package version
|
||||
|
||||
var (
|
||||
Version = "1.1.15"
|
||||
Version = "1.1.17"
|
||||
Repo = "MengMengCode/CLICD"
|
||||
)
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "clicd-frontend",
|
||||
"private": true,
|
||||
"version": "1.1.15",
|
||||
"version": "1.1.17",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -321,7 +321,12 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
||||
type="checkbox"
|
||||
checked={!!form.assign_ipv4}
|
||||
disabled={!ipv4Available}
|
||||
onChange={(event) => setForm({ ...form, assign_ipv4: event.target.checked, public_ipv4s: event.target.checked ? form.public_ipv4s : [] })}
|
||||
onChange={(event) => setForm({
|
||||
...form,
|
||||
assign_ipv4: event.target.checked,
|
||||
public_ipv4s: event.target.checked ? form.public_ipv4s : [],
|
||||
...(event.target.checked ? { assign_nat: false, port_mapping_count: 0, extra_ports: [] } : {}),
|
||||
})}
|
||||
className="mt-1"
|
||||
/>
|
||||
<span className="min-w-0">
|
||||
@@ -429,6 +434,7 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
||||
assign_nat: checked,
|
||||
port_mapping_count: checked ? Math.max(2, form.port_mapping_count || 2) : 0,
|
||||
extra_ports: [],
|
||||
...(checked ? { assign_ipv4: false, public_ipv4s: [], ipv4_count: 0 } : {}),
|
||||
})
|
||||
}}
|
||||
className="mt-1"
|
||||
@@ -680,9 +686,10 @@ function validateResourceInputs(form: CreateContainerRequest, maxVCPU: number, m
|
||||
|
||||
function normalizeCreateForm(form: CreateContainerRequest): CreateContainerRequest {
|
||||
const normalized = applyTemplateDefaults(form)
|
||||
const wantsNAT = normalized.assign_nat !== false
|
||||
const wantsIPv4 = !!normalized.assign_ipv4
|
||||
const wantsIPv6 = !!normalized.assign_ipv6
|
||||
// IPv4 and NAT are mutually exclusive
|
||||
const wantsNAT = wantsIPv4 ? false : normalized.assign_nat !== false
|
||||
const linuxTemplate = !isWindowsTemplate(normalized.template_id)
|
||||
const sshAuthMode = linuxTemplate ? (normalized.ssh_auth_mode || 'auto_password') : 'auto_password'
|
||||
return {
|
||||
|
||||
@@ -176,6 +176,8 @@ const endpointGroups: Array<{ title: string; endpoints: EndpointTuple[] }> = [
|
||||
['POST', '/api/v1/containers/{id}/port-mappings', '添加端口映射'],
|
||||
['PUT', '/api/v1/containers/{id}/port-mappings/{index}', '更新端口映射'],
|
||||
['DELETE', '/api/v1/containers/{id}/port-mappings/{index}', '删除端口映射'],
|
||||
['GET', '/api/v1/containers/{id}/firewall', '获取防火墙设置'],
|
||||
['PUT', '/api/v1/containers/{id}/firewall', '更新防火墙设置'],
|
||||
['GET', '/api/v1/snapshots', '快照总览'],
|
||||
['GET', '/api/v1/containers/{id}/snapshots', '容器快照'],
|
||||
['POST', '/api/v1/containers/{id}/snapshots', '创建快照'],
|
||||
@@ -817,6 +819,14 @@ const requestBodySamples: Record<string, Record<string, unknown>> = {
|
||||
limit: 64,
|
||||
},
|
||||
'POST /api/v1/security/check': { container_name: 'example-vm' },
|
||||
'PUT /api/v1/containers/{id}/firewall': {
|
||||
enabled: true,
|
||||
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 },
|
||||
],
|
||||
},
|
||||
'PUT /api/v1/security/settings': { auto_shutdown: false },
|
||||
'POST /api/v1/swap': { action: 'resize', size_mb: 16384 },
|
||||
'POST /api/v1/batch-create': {
|
||||
@@ -1025,6 +1035,29 @@ const responseSamples: Record<string, unknown> = {
|
||||
data: [{ container_port: 8081, host_port: 61320, protocol: 'tcp', description: 'HTTP' }],
|
||||
},
|
||||
'DELETE /api/v1/containers/{id}/port-mappings/{index}': { success: true, data: [] },
|
||||
'GET /api/v1/containers/{id}/firewall': {
|
||||
success: true,
|
||||
data: {
|
||||
enabled: true,
|
||||
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 },
|
||||
],
|
||||
},
|
||||
},
|
||||
'PUT /api/v1/containers/{id}/firewall': {
|
||||
success: true,
|
||||
message: 'Firewall updated',
|
||||
data: {
|
||||
enabled: true,
|
||||
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 },
|
||||
],
|
||||
},
|
||||
},
|
||||
'GET /api/v1/snapshots': { success: true, data: null },
|
||||
'GET /api/v1/containers/{id}/snapshots': {
|
||||
success: true,
|
||||
@@ -1139,6 +1172,9 @@ function endpointNoteFor(key: string) {
|
||||
if (key === 'POST /api/v1/batch-create') {
|
||||
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)。')
|
||||
}
|
||||
if (key === 'POST /api/v1/batch-action') {
|
||||
notes.push('action=reinstall 时可追加 template_id、ssh_auth_mode、ssh_password、ssh_public_key;其他 action 会忽略这些重装字段。')
|
||||
}
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Save,
|
||||
|
||||
Settings,
|
||||
Square,
|
||||
TerminalSquare,
|
||||
@@ -48,6 +47,7 @@ import {
|
||||
TrafficInfo,
|
||||
getEnabledImages,
|
||||
PortMapping,
|
||||
FirewallRule,
|
||||
reinstallContainer,
|
||||
resetSSHPassword,
|
||||
restartContainer,
|
||||
@@ -57,6 +57,7 @@ import {
|
||||
SnapshotSchedule,
|
||||
Template,
|
||||
updateContainerExpiry,
|
||||
updateFirewall,
|
||||
updateSnapshotQuota,
|
||||
updateSnapshotSchedule,
|
||||
restoreContainerSnapshot,
|
||||
@@ -163,6 +164,12 @@ export default function ContainerDetail() {
|
||||
const [snapshotBusy, setSnapshotBusy] = useState('')
|
||||
const [showSnapshotSchedule, setShowSnapshotSchedule] = useState(false)
|
||||
const [snapshotScheduleDraft, setSnapshotScheduleDraft] = useState({ intervalHours: 24, time: '03:00' })
|
||||
const [showFirewall, setShowFirewall] = useState(false)
|
||||
const [firewallEnabled, setFirewallEnabled] = useState(false)
|
||||
const [firewallRules, setFirewallRules] = useState<FirewallRule[]>([])
|
||||
const [firewallSaving, setFirewallSaving] = useState(false)
|
||||
const [editingFirewallRule, setEditingFirewallRule] = useState<FirewallRule | null>(null)
|
||||
const [showFirewallEditor, setShowFirewallEditor] = useState(false)
|
||||
|
||||
const fetchContainer = useCallback(async () => {
|
||||
if (!containerIdentifier) return
|
||||
@@ -410,6 +417,61 @@ export default function ContainerDetail() {
|
||||
}
|
||||
}
|
||||
|
||||
const openFirewall = () => {
|
||||
if (!container) return
|
||||
setFirewallEnabled(container.firewall_enabled || false)
|
||||
setFirewallRules(container.firewall_rules ? [...container.firewall_rules.map(r => ({ ...r }))] : [])
|
||||
setShowFirewall(true)
|
||||
}
|
||||
|
||||
const saveFirewall = async () => {
|
||||
if (!container) return
|
||||
setFirewallSaving(true)
|
||||
try {
|
||||
await updateFirewall(container.id, { enabled: firewallEnabled, rules: firewallRules })
|
||||
fetchContainer()
|
||||
} catch (err: any) {
|
||||
dialog.alert('错误', err?.response?.data?.message || '保存防火墙设置失败')
|
||||
} finally {
|
||||
setFirewallSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const addFirewallRule = () => {
|
||||
setEditingFirewallRule({
|
||||
id: '',
|
||||
direction: 'in',
|
||||
protocol: 'tcp',
|
||||
port: '',
|
||||
source_ip: '',
|
||||
action: 'DROP',
|
||||
description: '',
|
||||
enabled: true,
|
||||
})
|
||||
setShowFirewallEditor(true)
|
||||
}
|
||||
|
||||
const saveFirewallRule = (rule: FirewallRule) => {
|
||||
if (rule.id) {
|
||||
// Update existing
|
||||
setFirewallRules(firewallRules.map(r => r.id === rule.id ? rule : r))
|
||||
} else {
|
||||
// Add new with temporary ID
|
||||
const newRule = { ...rule, id: `tmp-${Date.now()}` }
|
||||
setFirewallRules([...firewallRules, newRule])
|
||||
}
|
||||
setShowFirewallEditor(false)
|
||||
setEditingFirewallRule(null)
|
||||
}
|
||||
|
||||
const deleteFirewallRule = (ruleId: string) => {
|
||||
setFirewallRules(firewallRules.filter(r => r.id !== ruleId))
|
||||
}
|
||||
|
||||
const toggleFirewallRule = (ruleId: string) => {
|
||||
setFirewallRules(firewallRules.map(r => r.id === ruleId ? { ...r, enabled: !r.enabled } : r))
|
||||
}
|
||||
|
||||
const openReinstall = async () => {
|
||||
try {
|
||||
const res = await getEnabledImages(container?.virtualization || 'lxc')
|
||||
@@ -773,23 +835,26 @@ export default function ContainerDetail() {
|
||||
const maxRAMMB = hostInfo?.ram.total_mb ? Number(hostInfo.ram.total_mb) : undefined
|
||||
const hasIndependentIPv4 = assignedIPv4List.length > 0
|
||||
const hasIndependentIPv6 = ipv6List.length > 0
|
||||
const hasIndependentIP = hasIndependentIPv4 || hasIndependentIPv6
|
||||
const defaultConnPort = isWindows ? 3389 : 22
|
||||
|
||||
let publicEndpoint = '-'
|
||||
let sshCommand = ''
|
||||
|
||||
if (container.ssh_port > 0) {
|
||||
if (hasIndependentIPv4) {
|
||||
// Direct connection via independent IPv4 — all ports forwarded
|
||||
publicEndpoint = `${assignedIPv4List[0]}:${defaultConnPort}`
|
||||
if (!isWindows) {
|
||||
sshCommand = `ssh root@${assignedIPv4List[0]}`
|
||||
}
|
||||
} else if (hasIndependentIPv6) {
|
||||
publicEndpoint = `[${ipv6List[0]}]:${defaultConnPort}`
|
||||
if (!isWindows) {
|
||||
sshCommand = `ssh root@[${ipv6List[0]}]`
|
||||
}
|
||||
} else if (container.ssh_port > 0) {
|
||||
// NAT port mapping mode
|
||||
publicEndpoint = `${publicHost}:${container.ssh_port}`
|
||||
sshCommand = `ssh -p ${container.ssh_port} root@${publicHost}`
|
||||
} else if (hasIndependentIP) {
|
||||
// Direct connection via independent IPv4 or IPv6
|
||||
const connIP = hasIndependentIPv4 ? assignedIPv4List[0] : `[${ipv6List[0]}]`
|
||||
publicEndpoint = `${connIP}:${defaultConnPort}`
|
||||
if (!isWindows) {
|
||||
sshCommand = `ssh root@${connIP}`
|
||||
}
|
||||
}
|
||||
const editingSSH = draft.index !== null && !!container.port_mappings?.[draft.index] && (
|
||||
container.port_mappings[draft.index].description === 'SSH' || container.port_mappings[draft.index].container_port === 22 ||
|
||||
@@ -879,7 +944,11 @@ export default function ContainerDetail() {
|
||||
<InfoTag color="blue">系统 {container.template}</InfoTag>
|
||||
<InfoTag color="slate">类型 {(container.virtualization || 'lxc').toUpperCase()}</InfoTag>
|
||||
<InfoTag color="emerald">内网 {container.ip || '-'}</InfoTag>
|
||||
<InfoTag color="amber">IPv4 NAT {hasNATQuota ? `${mappingCount} 条` : '未分配'}</InfoTag>
|
||||
{hasIndependentIPv4 ? (
|
||||
<InfoTag color="amber">独立 IPv4 {assignedIPv4List[0]}</InfoTag>
|
||||
) : (
|
||||
<InfoTag color="amber">IPv4 NAT {hasNATQuota ? `${mappingCount} 条` : '未分配'}</InfoTag>
|
||||
)}
|
||||
<InfoTag color="violet">{isWindows ? 'RDP' : 'SSH'} {publicEndpoint}</InfoTag>
|
||||
{isPolicyBlocked && <InfoTag color="red">策略封禁</InfoTag>}
|
||||
</div>
|
||||
@@ -922,22 +991,24 @@ export default function ContainerDetail() {
|
||||
管理链接
|
||||
</ActionButton>
|
||||
)}
|
||||
<>
|
||||
{!hasIndependentIPv4 && hasNATQuota && (
|
||||
<ActionButton disabled={isSubUserPolicyBlocked} onClick={() => setShowNat(true)}>
|
||||
<Settings className="w-3.5 h-3.5" />
|
||||
IPv4 NAT 管理
|
||||
</ActionButton>
|
||||
</>
|
||||
)}
|
||||
<ActionButton onClick={() => setShowFirewall(true)} disabled={isSubUserPolicyBlocked}>
|
||||
<FirewallIcon className="w-3.5 h-3.5" />
|
||||
防火墙
|
||||
</ActionButton>
|
||||
<ActionButton onClick={() => setShowSnapshots(true)} disabled={!!taskStatus || !!snapshotBusy || isSubUserPolicyBlocked}>
|
||||
<Camera className="w-3.5 h-3.5" />
|
||||
快照
|
||||
</ActionButton>
|
||||
{!isSubUser && (
|
||||
<ActionButton onClick={openReinstall} disabled={!!taskStatus || isExpired}>
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
{isExpired ? '已到期' : taskStatus === 'reinstall' ? taskActionLabels['reinstall'] : '重装'}
|
||||
</ActionButton>
|
||||
)}
|
||||
<ActionButton onClick={openReinstall} disabled={!!taskStatus || isExpired || isSubUserPolicyBlocked}>
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
{isExpired ? '已到期' : taskStatus === 'reinstall' ? taskActionLabels['reinstall'] : '重装'}
|
||||
</ActionButton>
|
||||
{!isSubUser && (
|
||||
<ActionButton disabled={!!taskStatus} onClick={() => handleAction('delete')}>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
@@ -961,7 +1032,7 @@ export default function ContainerDetail() {
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-5">
|
||||
<Panel
|
||||
title="连接信息"
|
||||
extra={!isSubUser && !isWindows && !isSubUserPolicyBlocked ? (
|
||||
extra={!isWindows && !isSubUserPolicyBlocked ? (
|
||||
<button
|
||||
onClick={openResetPassword}
|
||||
className="inline-flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs text-gray-600 hover:bg-gray-100 hover:text-black"
|
||||
@@ -1437,7 +1508,141 @@ export default function ContainerDetail() {
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{showNat && (
|
||||
{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">
|
||||
<Plus className="w-3.5 h-3.5" />添加规则
|
||||
</button>
|
||||
)
|
||||
}>
|
||||
<div className="space-y-5">
|
||||
{/* Global toggle */}
|
||||
<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>
|
||||
<button
|
||||
onClick={() => setFirewallEnabled(!firewallEnabled)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${firewallEnabled ? 'bg-emerald-500' : 'bg-gray-300'}`}
|
||||
>
|
||||
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${firewallEnabled ? 'translate-x-6' : 'translate-x-1'}`} />
|
||||
</button>
|
||||
</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">来源/目标 IP</th>
|
||||
<th className="px-3 py-2 text-left font-medium">动作</th>
|
||||
<th className="px-3 py-2 text-left font-medium">描述</th>
|
||||
{!isSubUser && <th className="px-3 py-2 text-right font-medium">操作</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{firewallRules.map((rule) => (
|
||||
<tr key={rule.id} className={!rule.enabled ? 'opacity-50' : ''}>
|
||||
<td className="px-3 py-2">
|
||||
<button onClick={() => toggleFirewallRule(rule.id)} className={`inline-flex h-4 w-7 items-center rounded-full transition-colors ${rule.enabled ? 'bg-emerald-500' : 'bg-gray-300'}`}>
|
||||
<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 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' ? '入站' : '出站'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 font-mono text-xs">{rule.protocol.toUpperCase()}</td>
|
||||
<td className="px-3 py-2 font-mono text-xs">{rule.port || '全部'}</td>
|
||||
<td className="px-3 py-2 font-mono text-xs">{rule.source_ip || '任意'}</td>
|
||||
<td className="px-3 py-2">
|
||||
<span className={`inline-flex px-1.5 py-0.5 rounded text-xs font-medium ${rule.action === 'ACCEPT' ? 'bg-emerald-50 text-emerald-700' : 'bg-red-50 text-red-700'}`}>
|
||||
{rule.action === 'ACCEPT' ? '放行' : '拒绝'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-xs text-gray-600 max-w-32 truncate">{rule.description || '-'}</td>
|
||||
{!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">
|
||||
<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">
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
{firewallRules.length === 0 && (
|
||||
<tr><td colSpan={isSubUser ? 7 : 8} className="px-3 py-6 text-center text-xs text-gray-400">暂无防火墙规则</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Save button */}
|
||||
{!isSubUser && (
|
||||
<div className="flex justify-end">
|
||||
<button onClick={saveFirewall} disabled={firewallSaving} className="inline-flex items-center gap-1.5 px-4 py-2 bg-black text-white rounded-md text-sm hover:bg-gray-800 disabled:opacity-50">
|
||||
<Save className="w-3.5 h-3.5" />
|
||||
{firewallSaving ? '保存中...' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{showFirewallEditor && editingFirewallRule && (
|
||||
<Modal title={editingFirewallRule.id ? '编辑规则' : '添加规则'} onClose={() => { setShowFirewallEditor(false); setEditingFirewallRule(null) }}>
|
||||
<div className="space-y-4">
|
||||
<Field label="方向">
|
||||
<select value={editingFirewallRule.direction} onChange={(e) => setEditingFirewallRule({ ...editingFirewallRule, direction: e.target.value as 'in' | 'out' })} className={inputClass}>
|
||||
<option value="in">入站 (Inbound)</option>
|
||||
<option value="out">出站 (Outbound)</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="协议">
|
||||
<select value={editingFirewallRule.protocol} onChange={(e) => setEditingFirewallRule({ ...editingFirewallRule, protocol: e.target.value as any })} 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>
|
||||
<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>
|
||||
<Field label="动作">
|
||||
<select value={editingFirewallRule.action} onChange={(e) => setEditingFirewallRule({ ...editingFirewallRule, action: e.target.value as 'ACCEPT' | 'DROP' })} className={inputClass}>
|
||||
<option value="ACCEPT">放行 (ACCEPT)</option>
|
||||
<option value="DROP">拒绝 (DROP)</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="描述">
|
||||
<input value={editingFirewallRule.description} onChange={(e) => setEditingFirewallRule({ ...editingFirewallRule, description: e.target.value })} placeholder="规则描述" className={inputClass} />
|
||||
</Field>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<button onClick={() => { setShowFirewallEditor(false); setEditingFirewallRule(null) }} className="px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 rounded-md">取消</button>
|
||||
<button onClick={() => saveFirewallRule(editingFirewallRule)} className="px-4 py-2 text-sm bg-black text-white rounded-md hover:bg-gray-800">确定</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{showNat && !hasIndependentIPv4 && (
|
||||
<Modal title="IPv4 NAT 端口管理" onClose={() => { setShowNat(false); setDraft(emptyDraft); setShowMappingEditor(false) }} wide extra={
|
||||
!isSubUser && canAddMapping && (
|
||||
<button onClick={openAddMapping} 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">
|
||||
@@ -1671,6 +1876,14 @@ function RangeSwitch({ value, onChange }: { value: StatsRangeKey; onChange: (val
|
||||
)
|
||||
}
|
||||
|
||||
function FirewallIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 1024 1024" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M979.989543 469.308394H757.450516c4.519899-21.887511 7.247838-45.094992 7.247838-69.798441 0-137.428929-116.773391-270.417958-121.72528-276.001833a21.415521 21.415521 0 0 0-21.887511-6.319858 21.287524 21.287524 0 0 0-15.103663 16.98362l-12.583719 75.438315C571.854663 148.115571 533.535519 69.229333 467.241 5.910748A21.46352 21.46352 0 0 0 441.585573 2.982813a21.295524 21.295524 0 0 0-9.727782 23.935466c15.703649 58.366696-2.815937 152.996581-22.911488 226.978928-5.591875-35.7912-15.615651-66.214521-32.935264-76.414293a21.351523 21.351523 0 0 0-32.167282 18.399589c0 31.359299-15.999643 60.278653-34.519228 93.813904-24.703448 44.759-52.734822 95.525866-52.734822 167.972247 0 4.055909 0.599987 7.727827 0.767983 11.64774H41.346516A21.343523 21.343523 0 0 0 20.010993 490.651917v511.98856a21.343523 21.343523 0 0 0 21.335523 21.335524H979.989543a21.343523 21.343523 0 0 0 21.335524-21.335524v-511.98856A21.343523 21.343523 0 0 0 979.989543 469.308394z m-149.332663 42.663047v127.99714H660.380685c33.879243-29.183348 65.878528-72.702376 85.334093-127.99714h84.942102zM346.699693 310.255948c7.559831-13.599696 14.895667-26.919399 21.167527-40.399098 3.495922 28.543362 5.503877 64.510559 5.071887 100.26176a21.311524 21.311524 0 0 0 17.367612 21.199526 21.255525 21.255525 0 0 0 23.935465-13.351701c3.071931-8.191817 63.918572-169.980202 65.958527-293.241448 78.462247 104.493665 96.429845 228.906885 96.63784 230.354853a21.279525 21.279525 0 0 0 20.823535 18.431588c9.85578-0.255994 19.631561-7.383835 21.335523-17.791602L640.077138 189.29865c32.895265 46.422963 81.958169 129.277111 81.958169 210.219303 0 157.772475-113.837456 240.458627-153.212577 240.458628H455.241268c-19.023575-5.247883-155.940516-47.742933-155.940516-182.347926 0-61.486626 24.111461-105.133651 47.398941-147.372707zM659.996693 682.647627v127.99714H361.339366v-127.99714H659.996693zM190.67118 511.971441h72.750374c15.311658 60.974638 54.910773 101.717727 93.693907 127.99714H190.67118v-127.99714z m-127.99714 0H148.008133v127.99714H62.67404v-127.99714z m0 170.668186h255.99428v127.99714h-255.99428v-127.99714zM148.008133 981.296954H62.67404v-127.99714H148.008133v127.99714z m341.328373 0H190.67118v-127.99714h298.665326v127.99714z m341.320374 0H531.999553v-127.99714h298.657327v127.99714z m127.99714 0h-85.326093v-127.99714h85.326093v127.99714z m0-170.660187h-255.99428v-127.99714h255.99428v127.99714z m0-170.668186h-85.326093v-127.99714h85.326093v127.99714z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusBadge({ running, initializing }: { running: boolean; initializing?: boolean }) {
|
||||
if (initializing) {
|
||||
return (
|
||||
@@ -2032,11 +2245,12 @@ function TableHead({ children }: { children: ReactNode }) {
|
||||
return <th className="text-left px-3 py-2 text-xs font-medium text-gray-500">{children}</th>
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: ReactNode }) {
|
||||
function Field({ label, children, hint }: { label: string; children: ReactNode; hint?: string }) {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="block text-xs font-medium text-gray-600 mb-1.5">{label}</span>
|
||||
{children}
|
||||
{hint && <span className="block text-[11px] text-gray-400 mt-1">{hint}</span>}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -724,6 +724,8 @@ function toPlaceholder(cfg: CreateContainerRequest): DisplayContainer {
|
||||
ssh_password: '',
|
||||
port_mappings: [],
|
||||
port_mapping_limit: cfg.assign_nat === false ? 0 : (cfg.port_mapping_count || 0),
|
||||
firewall_enabled: false,
|
||||
firewall_rules: [],
|
||||
snapshot_limit: cfg.snapshot_limit || 3,
|
||||
created_at: '',
|
||||
expires_at: cfg.expires_at,
|
||||
|
||||
@@ -128,7 +128,7 @@ export default function Login() {
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-xs text-gray-400 mt-6">CLICD v1.1.15</p>
|
||||
<p className="text-center text-xs text-gray-400 mt-6">CLICD v1.1.17</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||
import { Globe2, Network, Pencil, Plus, RefreshCw, Router, Save, Search, Server, Trash2, X } from 'lucide-react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useLanguage, type Language } from '../contexts/LanguageContext'
|
||||
@@ -23,7 +23,8 @@ export default function Routing() {
|
||||
const [ipv4EditMode, setIPv4EditMode] = useState<'pool' | 'address'>('pool')
|
||||
const [editingIPv4Address, setEditingIPv4Address] = useState('')
|
||||
const [savingIPv4, setSavingIPv4] = useState(false)
|
||||
const [ipv4Draft, setIPv4Draft] = useState<PublicIPv4Info[]>([])
|
||||
const [ipv4Draft, setIPv4Draft] = useState<(PublicIPv4Info & { _id: number })[]>([])
|
||||
const nextDraftId = useRef(0)
|
||||
const [nat4Page, setNat4Page] = useState(1)
|
||||
const [ipv6Page, setIPv6Page] = useState(1)
|
||||
const [nat4Search, setNat4Search] = useState('')
|
||||
@@ -54,7 +55,7 @@ export default function Routing() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!editingIPv4) {
|
||||
setIPv4Draft(publicIPv4s.map((ip) => ({ ...ip })))
|
||||
setIPv4Draft(publicIPv4s.map((ip) => ({ ...ip, _id: nextDraftId.current++ })))
|
||||
}
|
||||
}, [editingIPv4, publicIPv4s])
|
||||
|
||||
@@ -65,14 +66,14 @@ export default function Routing() {
|
||||
}, [ipv4Assignments])
|
||||
|
||||
const startEditIPv4 = () => {
|
||||
setIPv4Draft(publicIPv4s.map((ip) => ({ ...ip })))
|
||||
setIPv4Draft(publicIPv4s.map((ip) => ({ ...ip, _id: nextDraftId.current++ })))
|
||||
setIPv4EditMode('pool')
|
||||
setEditingIPv4Address('')
|
||||
setEditingIPv4(true)
|
||||
}
|
||||
|
||||
const startEditIPv4Address = (ip: PublicIPv4Info) => {
|
||||
setIPv4Draft([{ ...ip }])
|
||||
setIPv4Draft([{ ...ip, _id: nextDraftId.current++ }])
|
||||
setIPv4EditMode('address')
|
||||
setEditingIPv4Address(ip.address)
|
||||
setEditingIPv4(true)
|
||||
@@ -82,13 +83,14 @@ export default function Routing() {
|
||||
setEditingIPv4(false)
|
||||
setIPv4EditMode('pool')
|
||||
setEditingIPv4Address('')
|
||||
setIPv4Draft(publicIPv4s.map((ip) => ({ ...ip })))
|
||||
setIPv4Draft([])
|
||||
}
|
||||
|
||||
const addIPv4Row = () => {
|
||||
setIPv4Draft((items) => [
|
||||
...items,
|
||||
{
|
||||
_id: nextDraftId.current++,
|
||||
address: '',
|
||||
interface: defaultIPv4Interface,
|
||||
prefix: '',
|
||||
@@ -108,7 +110,7 @@ export default function Routing() {
|
||||
setSavingIPv4(true)
|
||||
try {
|
||||
const draftItems = ipv4Draft
|
||||
.map((item) => ({
|
||||
.map(({ _id, ...item }) => ({
|
||||
...item,
|
||||
address: (item.address || '').trim(),
|
||||
interface: (item.interface || defaultIPv4Interface).trim(),
|
||||
@@ -285,7 +287,7 @@ export default function Routing() {
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{ipv4Draft.map((item, index) => (
|
||||
<tr key={`${item.address}-${index}`}>
|
||||
<tr key={item._id}>
|
||||
<td className="px-3 py-2"><input value={item.address || ''} onChange={(e) => updateIPv4Draft(index, { address: e.target.value })} placeholder={text.ipv4CIDR} className={smallInputClass} /></td>
|
||||
<td className="px-3 py-2"><input value={item.gateway || ''} onChange={(e) => updateIPv4Draft(index, { gateway: e.target.value })} placeholder={defaultIPv4Gateway || text.gateway} className={smallInputClass} /></td>
|
||||
<td className="px-3 py-2"><input value={item.interface || ''} onChange={(e) => updateIPv4Draft(index, { interface: e.target.value })} placeholder={defaultIPv4Interface} className={smallInputClass} /></td>
|
||||
|
||||
@@ -45,6 +45,17 @@ export interface PortMapping {
|
||||
description: string
|
||||
}
|
||||
|
||||
export interface FirewallRule {
|
||||
id: string
|
||||
direction: 'in' | 'out'
|
||||
protocol: 'tcp' | 'udp' | 'icmp' | 'all'
|
||||
port: string
|
||||
source_ip: string
|
||||
action: 'ACCEPT' | 'DROP'
|
||||
description: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export interface PublicIPv4Assignment {
|
||||
address: string
|
||||
interface?: string
|
||||
@@ -88,6 +99,8 @@ export interface Container {
|
||||
ssh_password: string
|
||||
port_mappings: PortMapping[]
|
||||
port_mapping_limit: number
|
||||
firewall_enabled: boolean
|
||||
firewall_rules: FirewallRule[]
|
||||
snapshot_limit: number
|
||||
created_at: string
|
||||
expires_at: string
|
||||
@@ -487,6 +500,12 @@ export const updatePortMapping = (id: ContainerIdentifier, index: number, data:
|
||||
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`)
|
||||
|
||||
export const updateFirewall = (id: ContainerIdentifier, data: { enabled?: boolean; rules?: FirewallRule[] }) =>
|
||||
api.put<APIResponse<{ enabled: boolean; rules: FirewallRule[] }>>(`/containers/${id}/firewall`, data)
|
||||
|
||||
export const updateContainerExpiry = (id: ContainerIdentifier, expiresAt: string) =>
|
||||
api.put<APIResponse>(`/containers/${id}/expiry`, { expires_at: expiresAt })
|
||||
|
||||
|
||||
@@ -788,6 +788,37 @@ const exact: Record<string, string> = {
|
||||
'50 / 页': '50 / page',
|
||||
'全局快照列表,共': 'Global snapshot list, total',
|
||||
'容器分配的子用户列表,共': 'Sub-user list assigned to containers, total',
|
||||
'防火墙': 'Firewall',
|
||||
'防火墙设置': 'Firewall Settings',
|
||||
'独立 IPv4': 'Dedicated IPv4',
|
||||
'添加规则': 'Add Rule',
|
||||
'启用后默认拒绝所有入站和出站流量,仅放行下方规则': 'When enabled, all inbound and outbound traffic is blocked by default. Only the rules below are allowed.',
|
||||
'方向': 'Direction',
|
||||
'来源/目标 IP': 'Source / Destination IP',
|
||||
'动作': 'Action',
|
||||
'入站': 'Inbound',
|
||||
'出站': 'Outbound',
|
||||
'任意': 'Any',
|
||||
'放行': 'Allow',
|
||||
'拒绝': 'Deny',
|
||||
'暂无防火墙规则': 'No firewall rules',
|
||||
'编辑规则': 'Edit Rule',
|
||||
'入站 (Inbound)': 'Inbound',
|
||||
'出站 (Outbound)': 'Outbound',
|
||||
'留空为全部端口,支持: 22 | 80,443 | 8000-9000': 'Leave empty for all ports. Supports: 22 | 80,443 | 8000-9000',
|
||||
'如: 22 或 80,443 或 8000-9000': 'e.g. 22 or 80,443 or 8000-9000',
|
||||
'来源 IP': 'Source IP',
|
||||
'目标 IP': 'Destination IP',
|
||||
'留空为任意 IP,支持 CIDR: 192.168.1.0/24': 'Leave empty for any IP. Supports CIDR: 192.168.1.0/24',
|
||||
'如: 192.168.1.0/24': 'e.g. 192.168.1.0/24',
|
||||
'放行 (ACCEPT)': 'Allow (ACCEPT)',
|
||||
'拒绝 (DROP)': 'Deny (DROP)',
|
||||
'规则描述': 'Rule description',
|
||||
'登录方式': 'SSH Auth Method',
|
||||
'保留当前密码': 'Keep current password',
|
||||
'生成新密码': 'Generate new password',
|
||||
'自定义密码': 'Custom password',
|
||||
'生成密码': 'Generate password',
|
||||
}
|
||||
|
||||
const artifactPatterns: RegExp[] = [
|
||||
@@ -853,6 +884,7 @@ const replacements: Array<[RegExp, string]> = [
|
||||
[/搜索\s*"([^"]+)"\s*结果\s*(\d+)\s*个地址/g, 'Search "$1" returned $2 addresses, '],
|
||||
[/(\d+)\s*个/g, '$1 items'],
|
||||
[/(\d+)\s*条/g, '$1 records'],
|
||||
[/1\s*核\b/g, '1 core'],
|
||||
[/(\d+)\s*核/g, '$1 cores'],
|
||||
[/(\d+)\s*线程/g, '$1 threads'],
|
||||
[/已用/g, 'used'],
|
||||
|
||||
Reference in New Issue
Block a user