diff --git a/backend/internal/kvm/kvm.go b/backend/internal/kvm/kvm.go index 5387940..6ad8635 100644 --- a/backend/internal/kvm/kvm.go +++ b/backend/internal/kvm/kvm.go @@ -429,6 +429,7 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig ipv6Assignments = assigned } ipv6List := configIPv6AssignmentAddresses(ipv6Assignments) + ipv4List := configIPv4AssignmentAddresses(publicIPv4s) defaultHostIP := lxc.DefaultPortMappingHostIP(publicIPv4s) var xml string @@ -448,7 +449,7 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig } winAdminPassword = generateWindowsPassword() unattendPath := filepath.Join(m.instanceDir(vmName), "unattend.iso") - if err := createWindowsUnattendISO(unattendPath, cfg.Name, winAdminPassword, ipv6List); err != nil { + if err := createWindowsUnattendISO(unattendPath, cfg.Name, winAdminPassword, ipv6List, ipv4List); err != nil { return nil, err } xml = windowsDomainXML(vmName, int(cfg.VCPU), cfg.RAMMB, diskPath, ImagePath(image.ID), unattendPath, mac, cfg.IOSpeedMBps, cfg.NetworkBWMbps) @@ -464,7 +465,7 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig if err := createOverlayDisk(ImagePath(image.ID), diskPath, cfg.DiskGB); err != nil { return nil, err } - if err := createSeedISO(seedPath, vmName, cfg.Name, sshPassword, sshPublicKey, mac, ipv6List, *image); err != nil { + if err := createSeedISO(seedPath, vmName, cfg.Name, sshPassword, sshPublicKey, mac, ipv6List, ipv4List, *image); err != nil { return nil, err } xml = domainXML(vmName, int(cfg.VCPU), cfg.RAMMB, diskPath, seedPath, mac, cfg.IOSpeedMBps, cfg.NetworkBWMbps, image.Desktop != "") @@ -1389,7 +1390,7 @@ func (m *Manager) RefreshNetwork(id int) (string, error) { if changed { config.SaveConfig() } - if c.Status == "running" && len(c.PortMappings) > 0 && shouldApplyPortMappings(id, changed) { + if c.Status == "running" && shouldApplyPortMappings(id, changed) { if err := lxc.NewManager().ApplyPortMappings(id); err != nil { return ip, err } @@ -1596,7 +1597,7 @@ func createEmptyDisk(target string, diskGB int) error { return nil } -func createWindowsUnattendISO(target, hostname, adminPassword string, ipv6s []string) error { +func createWindowsUnattendISO(target, hostname, adminPassword string, ipv6s []string, ipv4s []string) error { tool := firstAvailableCommand("genisoimage", "mkisofs", "xorriso") if tool == "" { return fmt.Errorf("one of genisoimage, mkisofs, xorriso is required for Windows unattended setup") @@ -1622,13 +1623,13 @@ func createWindowsUnattendISO(target, hostname, adminPassword string, ipv6s []st if err := os.WriteFile(filepath.Join(setupScriptsDir, "SetupComplete.cmd"), []byte(windowsSetupCompleteCMD()), 0600); err != nil { return err } - if err := os.WriteFile(filepath.Join(clicdDir, "FirstLogon.ps1"), []byte(windowsFirstLogonPowerShell(adminPassword, ipv6s)), 0600); err != nil { + if err := os.WriteFile(filepath.Join(clicdDir, "FirstLogon.ps1"), []byte(windowsFirstLogonPowerShell(adminPassword, ipv6s, ipv4s)), 0600); err != nil { return err } if err := os.WriteFile(filepath.Join(dir, "SetupComplete.cmd"), []byte(windowsSetupCompleteCMD()), 0600); err != nil { return err } - if err := os.WriteFile(filepath.Join(dir, "FirstLogon.ps1"), []byte(windowsFirstLogonPowerShell(adminPassword, ipv6s)), 0600); err != nil { + if err := os.WriteFile(filepath.Join(dir, "FirstLogon.ps1"), []byte(windowsFirstLogonPowerShell(adminPassword, ipv6s, ipv4s)), 0600); err != nil { return err } _ = os.Remove(target) @@ -1738,7 +1739,7 @@ exit /b 0 ` } -func windowsFirstLogonPowerShell(adminPassword string, ipv6s []string) string { +func windowsFirstLogonPowerShell(adminPassword string, ipv6s []string, ipv4s []string) string { commands := []string{ "$ErrorActionPreference='Continue'", "$ProgressPreference='SilentlyContinue'", @@ -1774,6 +1775,12 @@ func windowsFirstLogonPowerShell(adminPassword string, ipv6s []string) string { windowsIPv6PowerShell(ipv6s), ) } + ipv4s = normalizeKVMIPv4List(ipv4s) + if len(ipv4s) > 0 { + commands = append(commands, + windowsIPv4PowerShell(ipv4s), + ) + } commands = append(commands, "New-Item -ItemType File -Force -Path 'C:\\CLICD\\init.done' | Out-Null", "} finally { Stop-Transcript | Out-Null }", @@ -1806,11 +1813,47 @@ func windowsIPv6PowerShell(ipv6s []string) string { }, "\r\n") } +func windowsIPv4PowerShell(ipv4s []string) string { + ipv4s = normalizeKVMIPv4List(ipv4s) + if len(ipv4s) == 0 { + return "" + } + quoted := make([]string, 0, len(ipv4s)) + for _, ipv4 := range ipv4s { + quoted = append(quoted, "'"+strings.ReplaceAll(ipv4, "'", "''")+"'") + } + 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 } }", + "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", + " New-NetIPAddress -IPAddress $ip -PrefixLength 32 -InterfaceIndex $iface.ifIndex -SkipAsSource:$false -ErrorAction SilentlyContinue | Out-Null", + " }", + "}", + }, "\r\n") +} + +func normalizeKVMIPv4List(values []string) []string { + seen := map[string]bool{} + result := make([]string, 0, len(values)) + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" || seen[value] { + continue + } + seen[value] = true + result = append(result, value) + } + return result +} + func shellQuoteWindows(value string) string { return `"` + strings.ReplaceAll(value, `"`, `\"`) + `"` } -func createSeedISO(seedPath, instanceID, hostname, password, publicKey, mac string, ipv6s []string, image Image) error { +func createSeedISO(seedPath, instanceID, hostname, password, publicKey, mac string, ipv6s []string, ipv4s []string, image Image) error { guestSetup := kvmSSHSetupScript(password, publicKey) if desktopSetup := kvmDesktopSetupScript(image); desktopSetup != "" { guestSetup += "\n" + desktopSetup @@ -1846,29 +1889,40 @@ runcmd: %s `, hostname, password, authorizedKeys, setupScript) metaData := fmt.Sprintf("instance-id: %s\nlocal-hostname: %s\n", instanceID, hostname) - ipv6Block := "" + + // Build static address block (IPv4 + IPv6) + ipv4s = normalizeKVMIPv4List(ipv4s) + addressBlock := "" + addressLines := make([]string, 0, len(ipv4s)+len(ipv6s)) + for _, ipv4 := range ipv4s { + addressLines = append(addressLines, fmt.Sprintf(" - %s/32", ipv4)) + } + for _, ipv6 := range ipv6s { + addressLines = append(addressLines, fmt.Sprintf(" - %s/128", ipv6)) + } + if len(addressLines) > 0 { + addressBlock = fmt.Sprintf("\n addresses:\n%s", strings.Join(addressLines, "\n")) + } + + // IPv6 routes (only needed when IPv6 addresses are configured) + ipv6RouteBlock := "" if len(ipv6s) > 0 { - addressLines := make([]string, 0, len(ipv6s)) - for _, ipv6 := range ipv6s { - addressLines = append(addressLines, fmt.Sprintf(" - %s/128", ipv6)) - } - ipv6Block = fmt.Sprintf(` - addresses: -%s + ipv6RouteBlock = fmt.Sprintf(` routes: - to: default via: %s on-link: true - metric: 100`, strings.Join(addressLines, "\n"), ipv6GatewayLinkLocal) + metric: 100`, ipv6GatewayLinkLocal) } + networkConfig := fmt.Sprintf(`version: 2 ethernets: nic0: match: macaddress: "%s" dhcp4: true - dhcp6: false%s -`, strings.ToLower(mac), ipv6Block) + dhcp6: false%s%s +`, strings.ToLower(mac), addressBlock, ipv6RouteBlock) dir := filepath.Dir(seedPath) userPath := filepath.Join(dir, "user-data") metaPath := filepath.Join(dir, "meta-data") @@ -1900,6 +1954,16 @@ func configIPv6AssignmentAddresses(assignments []config.IPv6Assignment) []string return values } +func configIPv4AssignmentAddresses(assignments []config.PublicIPv4Assignment) []string { + values := make([]string, 0, len(assignments)) + for _, item := range assignments { + if strings.TrimSpace(item.Address) != "" { + values = append(values, strings.TrimSpace(item.Address)) + } + } + return values +} + func normalizeKVMIPv6List(values []string) []string { seen := map[string]bool{} result := make([]string, 0, len(values)) diff --git a/backend/internal/lxc/portmap.go b/backend/internal/lxc/portmap.go index ddc577e..f53ef34 100644 --- a/backend/internal/lxc/portmap.go +++ b/backend/internal/lxc/portmap.go @@ -59,11 +59,60 @@ 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 { + ensureIndependentIPv4Ingress(c, tag) + } + applyIPv4EgressPolicy(c, bridge, subnet, tag) return nil } +func ensureIndependentIPv4Ingress(c *config.Container, tag string) { + if c == nil || c.IP == "" || len(c.PublicIPv4s) == 0 { + 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 { + args := []string{ + "-t", "nat", + "-I", "PREROUTING", "1", + "-d", hostIP, + "-p", svc.protocol, + "--dport", fmt.Sprintf("%d", svc.port), + "-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), + } + 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)) + continue + } + fmt.Printf("%s ingress: %s:%d -> %s:%d\n", svc.desc, hostIP, svc.port, c.IP, svc.port) + } + } +} + func applyIPv4EgressPolicy(c *config.Container, bridge, subnet, tag string) { if c == nil || strings.TrimSpace(c.IP) == "" { return diff --git a/backend/internal/server/web/.gitkeep b/backend/internal/server/web/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/frontend/src/pages/ContainerDetail.tsx b/frontend/src/pages/ContainerDetail.tsx index 3ea3cf9..71af8fa 100644 --- a/frontend/src/pages/ContainerDetail.tsx +++ b/frontend/src/pages/ContainerDetail.tsx @@ -770,8 +770,26 @@ export default function ContainerDetail() { if (ipv6List.length === 0 && container.ipv6) ipv6List.push(container.ipv6) const maxVCPU = hostInfo?.cpu.cores || 64 const maxRAMMB = hostInfo?.ram.total_mb ? Number(hostInfo.ram.total_mb) : undefined - const publicEndpoint = container.ssh_port > 0 ? `${publicHost}:${container.ssh_port}` : '-' - const sshCommand = container.ssh_port > 0 ? `ssh -p ${container.ssh_port} root@${publicHost}` : '' + 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) { + // 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 || container.port_mappings[draft.index].description === 'RDP' || container.port_mappings[draft.index].container_port === 3389