Compare commits

...

5 Commits

Author SHA1 Message Date
MengMengCode fbc539ea47 release: v1.1.17 2026-06-12 01:35:17 +08:00
MengMengCode 875cd4716b 增加了服务器防火墙功能 2026-06-12 01:35:04 +08:00
MengMengCode 6194b6e364 修复了一些已知问题 2026-06-11 23:29:22 +08:00
MengMengCode 30d6b2d9f7 release: v1.1.16 2026-06-11 23:09:49 +08:00
MengMengCode 2f94498df2 修复了一些已知问题 2026-06-11 23:09:04 +08:00
20 changed files with 782 additions and 75 deletions
+1 -1
View File
@@ -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
+154
View File
@@ -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
}
+10
View File
@@ -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
+10 -1
View File
@@ -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
+13
View File
@@ -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"`
+26 -4
View File
@@ -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
+28 -6
View File
@@ -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 {
+8 -7
View File
@@ -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
View File
@@ -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()
}
}
+1
View File
@@ -0,0 +1 @@
+1 -1
View File
@@ -1,7 +1,7 @@
package version
var (
Version = "1.1.15"
Version = "1.1.17"
Repo = "MengMengCode/CLICD"
)
+1 -1
View File
@@ -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 {
+36
View File
@@ -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 会忽略这些重装字段。')
}
+236 -22
View File
@@ -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>
)
}
+2
View File
@@ -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,
+1 -1
View File
@@ -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>
)
+10 -8
View File
@@ -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>
+19
View File
@@ -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 })
+32
View File
@@ -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'],