diff --git a/backend/internal/kvm/kvm.go b/backend/internal/kvm/kvm.go index bbee6f8..6f9c874 100644 --- a/backend/internal/kvm/kvm.go +++ b/backend/internal/kvm/kvm.go @@ -432,7 +432,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 := "" @@ -1850,8 +1851,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 +1875,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", diff --git a/backend/internal/lxc/lxc.go b/backend/internal/lxc/lxc.go index 810f64f..7b2fc48 100644 --- a/backend/internal/lxc/lxc.go +++ b/backend/internal/lxc/lxc.go @@ -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), }) diff --git a/backend/internal/lxc/portmap.go b/backend/internal/lxc/portmap.go index f53ef34..beb52f0 100644 --- a/backend/internal/lxc/portmap.go +++ b/backend/internal/lxc/portmap.go @@ -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) } } } diff --git a/backend/internal/server/web/.gitkeep b/backend/internal/server/web/.gitkeep new file mode 100644 index 0000000..8d1c8b6 --- /dev/null +++ b/backend/internal/server/web/.gitkeep @@ -0,0 +1 @@ + diff --git a/frontend/src/components/CreateContainerModal.tsx b/frontend/src/components/CreateContainerModal.tsx index 1a207cd..d78d7cd 100644 --- a/frontend/src/components/CreateContainerModal.tsx +++ b/frontend/src/components/CreateContainerModal.tsx @@ -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" /> @@ -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 { diff --git a/frontend/src/pages/ContainerDetail.tsx b/frontend/src/pages/ContainerDetail.tsx index a89e8d5..c436954 100644 --- a/frontend/src/pages/ContainerDetail.tsx +++ b/frontend/src/pages/ContainerDetail.tsx @@ -773,23 +773,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 +882,11 @@ export default function ContainerDetail() { 系统 {container.template} 类型 {(container.virtualization || 'lxc').toUpperCase()} 内网 {container.ip || '-'} - IPv4 NAT {hasNATQuota ? `${mappingCount} 条` : '未分配'} + {hasIndependentIPv4 ? ( + 独立 IPv4 {assignedIPv4List[0]} + ) : ( + IPv4 NAT {hasNATQuota ? `${mappingCount} 条` : '未分配'} + )} {isWindows ? 'RDP' : 'SSH'} {publicEndpoint} {isPolicyBlocked && 策略封禁} @@ -922,12 +929,14 @@ export default function ContainerDetail() { 管理链接 )} - <> - setShowNat(true)}> - - IPv4 NAT 管理 - - + {!hasIndependentIPv4 && ( + <> + setShowNat(true)}> + + IPv4 NAT 管理 + + + )} setShowSnapshots(true)} disabled={!!taskStatus || !!snapshotBusy || isSubUserPolicyBlocked}> 快照 @@ -1437,7 +1446,7 @@ export default function ContainerDetail() { )} - {showNat && ( + {showNat && !hasIndependentIPv4 && ( { setShowNat(false); setDraft(emptyDraft); setShowMappingEditor(false) }} wide extra={ !isSubUser && canAddMapping && (