diff --git a/backend/internal/api/handlers.go b/backend/internal/api/handlers.go index 69551d5..92638e4 100644 --- a/backend/internal/api/handlers.go +++ b/backend/internal/api/handlers.go @@ -172,6 +172,16 @@ func HandleSingleContainer(w http.ResponseWriter, r *http.Request) { return } assignIPv6(w, r, id) + case action == "public-ipv4" && r.Method == http.MethodPut: + if !requireScope(w, r, "container:network") { + return + } + updatePublicIPv4(w, r, id) + case action == "ipv6-addresses" && r.Method == http.MethodPut: + if !requireScope(w, r, "ipv6:assign") { + return + } + updateIPv6Addresses(w, r, id) case action == "snapshots" || strings.HasPrefix(action, "snapshots/"): handleContainerSnapshots(w, r, id, action) case action == "port-mappings" && r.Method == http.MethodPost: diff --git a/backend/internal/api/ipv6.go b/backend/internal/api/ipv6.go index 384b03a..b5f9259 100644 --- a/backend/internal/api/ipv6.go +++ b/backend/internal/api/ipv6.go @@ -1,6 +1,9 @@ package api -import "net/http" +import ( + "encoding/json" + "net/http" +) func HandleIPv6Status(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { @@ -22,3 +25,54 @@ func assignIPv6(w http.ResponseWriter, r *http.Request, id int) { } jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "IPv6 assigned", Data: c}) } + +type ipAssignmentRequest struct { + Mode string `json:"mode"` + Auto *bool `json:"auto,omitempty"` + Count int `json:"count,omitempty"` + Addresses []string `json:"addresses,omitempty"` +} + +func (req ipAssignmentRequest) allocation() ([]string, int, bool) { + auto := req.Mode == "random" || req.Mode == "auto" + if req.Mode == "custom" { + auto = false + } + if req.Mode == "clear" || req.Mode == "none" { + return nil, 0, false + } + if req.Auto != nil { + auto = *req.Auto + } + return req.Addresses, req.Count, auto +} + +func updatePublicIPv4(w http.ResponseWriter, r *http.Request, id int) { + var req ipAssignmentRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"}) + return + } + addresses, count, auto := req.allocation() + c, err := updatePublicIPv4ByRuntime(id, addresses, count, auto) + if err != nil { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()}) + return + } + jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Public IPv4 assignments updated", Data: c}) +} + +func updateIPv6Addresses(w http.ResponseWriter, r *http.Request, id int) { + var req ipAssignmentRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"}) + return + } + addresses, count, auto := req.allocation() + c, err := updateIPv6ByRuntime(id, addresses, count, auto) + if err != nil { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()}) + return + } + jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "IPv6 assignments updated", Data: c}) +} diff --git a/backend/internal/api/runtime.go b/backend/internal/api/runtime.go index 4d3c8c2..5327099 100644 --- a/backend/internal/api/runtime.go +++ b/backend/internal/api/runtime.go @@ -115,6 +115,22 @@ func assignIPv6ByRuntime(id int) (*config.Container, error) { return lxcManager.AssignIPv6(id) } +func updatePublicIPv4ByRuntime(id int, requested []string, count int, auto bool) (*config.Container, error) { + c := config.FindContainer(id) + if c != nil && c.IsKVM() { + return kvmManager.UpdatePublicIPv4Assignments(id, requested, count, auto) + } + return lxcManager.UpdatePublicIPv4Assignments(id, requested, count, auto) +} + +func updateIPv6ByRuntime(id int, requested []string, count int, auto bool) (*config.Container, error) { + c := config.FindContainer(id) + if c != nil && c.IsKVM() { + return kvmManager.UpdateIPv6Assignments(id, requested, count, auto) + } + return lxcManager.UpdateIPv6Assignments(id, requested, count, auto) +} + func usageByRuntime(id int) (map[string]interface{}, error) { c := config.FindContainer(id) if c != nil && c.IsKVM() { diff --git a/backend/internal/config/store_sqlite.go b/backend/internal/config/store_sqlite.go index f1f45d4..5620705 100644 --- a/backend/internal/config/store_sqlite.go +++ b/backend/internal/config/store_sqlite.go @@ -506,13 +506,17 @@ func ensureSchemaMigrations() error { } } if _, err := db.Exec(`UPDATE containers - SET lan_ipv4_address = COALESCE(lan_ipv4_address, ''), + SET lan_ipv4_mode = COALESCE(lan_ipv4_mode, ''), + lan_interface = COALESCE(lan_interface, ''), + lan_ipv4_address = COALESCE(lan_ipv4_address, ''), lan_ipv4_prefix_len = COALESCE(lan_ipv4_prefix_len, 0), lan_ipv4_gateway = COALESCE(lan_ipv4_gateway, '')`); err != nil { return err } if _, err := db.Exec(`UPDATE tasks - SET cfg_lan_ipv4_address = COALESCE(cfg_lan_ipv4_address, ''), + SET cfg_lan_ipv4_mode = COALESCE(cfg_lan_ipv4_mode, ''), + cfg_lan_interface = COALESCE(cfg_lan_interface, ''), + cfg_lan_ipv4_address = COALESCE(cfg_lan_ipv4_address, ''), cfg_lan_ipv4_prefix_len = COALESCE(cfg_lan_ipv4_prefix_len, 0), cfg_lan_ipv4_gateway = COALESCE(cfg_lan_ipv4_gateway, '')`); err != nil { return err @@ -975,6 +979,7 @@ func loadContainers() ([]Container, error) { var scheduleEnabled, policyBlocked, firewallEnabled, imageLimitConfigured int var firewallDefaultAction string var firewallRulesJSON, allowedImageIDs sql.NullString + var lanIPv4Mode, lanInterface sql.NullString var lanIPv4Address, lanIPv4Gateway sql.NullString var lanIPv4PrefixLen sql.NullInt64 if err := rows.Scan( @@ -983,7 +988,7 @@ func loadContainers() ([]Container, error) { &c.MonthlyTrafficGB, &c.TrafficMode, &c.TrafficInGB, &c.TrafficOutGB, &c.TrafficUsedRX, &c.TrafficUsedTX, &c.TrafficResetDate, &c.IOSpeedMBps, &c.IOReadMBps, &c.IOWriteMBps, - &c.Status, &c.IP, &c.LANIPv4Mode, &c.LANInterface, &lanIPv4Address, &lanIPv4PrefixLen, &lanIPv4Gateway, + &c.Status, &c.IP, &lanIPv4Mode, &lanInterface, &lanIPv4Address, &lanIPv4PrefixLen, &lanIPv4Gateway, &c.IPv6, &c.IPv6PrefixLen, &c.IPv6Interface, &c.VNCPort, &c.SSHPort, &c.SSHPassword, &c.SSHHostKey, &c.PortMappingLimit, &c.SnapshotLimit, &c.CreatedAt, &c.ExpiresAt, &scheduleEnabled, &c.SnapshotScheduleIntervalHours, &c.SnapshotScheduleTime, @@ -993,6 +998,8 @@ func loadContainers() ([]Container, error) { ); err != nil { return nil, err } + c.LANIPv4Mode = lanIPv4Mode.String + c.LANInterface = lanInterface.String c.LANIPv4Address = lanIPv4Address.String if lanIPv4PrefixLen.Valid { c.LANIPv4PrefixLen = int(lanIPv4PrefixLen.Int64) diff --git a/backend/internal/kvm/kvm.go b/backend/internal/kvm/kvm.go index 1cb93f8..61f686c 100644 --- a/backend/internal/kvm/kvm.go +++ b/backend/internal/kvm/kvm.go @@ -3363,6 +3363,109 @@ func (m *Manager) AssignIPv6(id int) (*config.Container, error) { return c, nil } +func (m *Manager) UpdatePublicIPv4Assignments(id int, requested []string, count int, auto bool) (*config.Container, error) { + c := config.FindContainer(id) + if c == nil { + return nil, fmt.Errorf("container not found: %d", id) + } + if !c.IsKVM() { + return nil, fmt.Errorf("container is not a KVM VM: %d", id) + } + + assignments := []config.PublicIPv4Assignment{} + if auto || len(requested) > 0 { + allocated, err := lxc.AllocatePublicIPv4Assignments(id, requested, count, auto) + if err != nil { + return nil, err + } + assignments = allocated + } + + c.PublicIPv4s = assignments + reconcileKVMPortMappingHostIPs(c) + c.NormalizeNetworkAssignments() + config.SaveConfig() + + lxcManager := lxc.NewManager() + _ = lxcManager.CleanPortMappings(id) + lxc.EnsureAssignedPublicIPv4s(c.PublicIPv4s) + if c.Status == "running" && c.IP != "" { + if err := lxcManager.ApplyPortMappings(id); err != nil { + return nil, err + } + } + return c, nil +} + +func reconcileKVMPortMappingHostIPs(c *config.Container) { + if c == nil { + return + } + assigned := map[string]bool{} + for _, item := range c.PublicIPv4s { + if addr := strings.TrimSpace(item.Address); addr != "" { + assigned[addr] = true + } + } + replacement := "" + if len(assigned) == 1 { + for addr := range assigned { + replacement = addr + } + } + for i := range c.PortMappings { + hostIP := strings.TrimSpace(c.PortMappings[i].HostIP) + if hostIP == "" || assigned[hostIP] { + continue + } + c.PortMappings[i].HostIP = replacement + } +} + +func (m *Manager) UpdateIPv6Assignments(id int, requested []string, count int, auto bool) (*config.Container, error) { + c := config.FindContainer(id) + if c == nil { + return nil, fmt.Errorf("container not found: %d", id) + } + if !c.IsKVM() { + return nil, fmt.Errorf("container is not a KVM VM: %d", id) + } + + old := *c + old.IPv6Addresses = append([]config.IPv6Assignment(nil), c.IPv6Addresses...) + removeKVMIPv6Runtime(&old) + + assignments := []config.IPv6Assignment{} + if auto || len(requested) > 0 { + allocated, err := m.allocateIPv6AssignmentsForContainer(id, requested, count, auto) + if err != nil { + if old.IPv6 != "" || len(old.IPv6Addresses) > 0 { + _ = m.applyIPv6Runtime(&old) + } + return nil, err + } + assignments = allocated + } + + c.IPv6 = "" + c.IPv6PrefixLen = 0 + c.IPv6Interface = "" + c.IPv6Addresses = assignments + c.NormalizeNetworkAssignments() + config.SaveConfig() + + if len(c.IPv6Addresses) > 0 { + if err := m.applyIPv6Runtime(c); err != nil { + return nil, err + } + } else if c.Status == "running" { + if err := lxc.ApplyFirewallRules(c.ID); err != nil { + fmt.Printf("Warning: failed to re-apply firewall rules after KVM IPv6 removal for %s: %v\n", c.Name, err) + } + } + return c, nil +} + func (m *Manager) applyIPv6Runtime(c *config.Container) error { if c == nil || (c.IPv6 == "" && len(c.IPv6Addresses) == 0) { return nil diff --git a/backend/internal/lxc/ipv6.go b/backend/internal/lxc/ipv6.go index ab074ce..2f2c550 100644 --- a/backend/internal/lxc/ipv6.go +++ b/backend/internal/lxc/ipv6.go @@ -1515,6 +1515,75 @@ func (m *Manager) AssignIPv6(id int) (*config.Container, error) { return c, nil } +func (m *Manager) UpdateIPv6Assignments(id int, requested []string, count int, auto bool) (*config.Container, error) { + c := config.FindContainer(id) + if c == nil { + return nil, fmt.Errorf("container not found: %d", id) + } + + oldAssignments := append([]config.IPv6Assignment(nil), c.IPv6Addresses...) + oldPrimary := c.IPv6 + oldPrimaryPrefixLen := c.IPv6PrefixLen + oldPrimaryInterface := c.IPv6Interface + + assignments := []config.IPv6Assignment{} + if auto || len(requested) > 0 { + allocated, err := m.allocateIPv6AssignmentsForContainer(id, requested, count, auto) + if err != nil { + return nil, err + } + assignments = allocated + } + + for _, assignment := range oldAssignments { + uplink := assignment.Interface + if uplink == "" { + uplink = oldPrimaryInterface + } + removeHostIPv6Routing(assignment.Address, uplink) + } + if len(oldAssignments) == 0 && oldPrimary != "" { + removeHostIPv6Routing(oldPrimary, oldPrimaryInterface) + oldAssignments = append(oldAssignments, config.IPv6Assignment{Address: oldPrimary, PrefixLen: oldPrimaryPrefixLen, Interface: oldPrimaryInterface}) + } + + c.IPv6 = "" + c.IPv6PrefixLen = 0 + c.IPv6Interface = "" + c.IPv6Addresses = assignments + c.NormalizeNetworkAssignments() + config.SaveConfig() + + if err := m.applyIPv6Config(c.LxcName(), c.IPv6AddressStrings()...); err != nil { + return nil, err + } + rootfsPath := filepath.Join(m.LxcPath, c.LxcName(), "rootfs") + if _, err := os.Stat(rootfsPath); err == nil { + if len(c.IPv6Addresses) == 0 { + if err := removeContainerIPv6Init(rootfsPath); err != nil { + fmt.Printf("Warning: failed to remove IPv6 init in %s: %v\n", c.LxcName(), err) + } + } else if err := installContainerIPv6Init(rootfsPath, c.IPv6AddressStrings()...); err != nil { + fmt.Printf("Warning: failed to install IPv6 init in %s: %v\n", c.LxcName(), err) + } + } + status, _ := m.GetContainerStatus(c.LxcName()) + if status == "running" { + m.removeGuestIPv6Addresses(c.LxcName(), oldAssignments) + } + if len(c.IPv6Addresses) > 0 { + if err := m.ApplyIPv6(id); err != nil { + return nil, err + } + } else if status == "running" { + m.removeGuestIPv6DefaultRoute(c.LxcName()) + if err := ApplyFirewallRules(c.ID); err != nil { + fmt.Printf("Warning: failed to re-apply firewall rules after IPv6 removal for %s: %v\n", c.Name, err) + } + } + return c, nil +} + func (m *Manager) applyIPv6Config(lxcName string, ipv6s ...string) error { configFile := filepath.Join(m.LxcPath, lxcName, "config") data, err := os.ReadFile(configFile) @@ -1705,6 +1774,25 @@ exit 0 return nil } +func removeContainerIPv6Init(rootfsPath string) error { + paths := []string{ + filepath.Join(rootfsPath, "usr", "local", "sbin", "clicd-ipv6-init"), + filepath.Join(rootfsPath, "etc", "systemd", "system", "clicd-ipv6.service"), + filepath.Join(rootfsPath, "etc", "systemd", "system", "multi-user.target.wants", "clicd-ipv6.service"), + filepath.Join(rootfsPath, "etc", "init.d", "clicd-ipv6"), + filepath.Join(rootfsPath, "etc", "runlevels", "default", "clicd-ipv6"), + } + for _, level := range []string{"2", "3", "4", "5"} { + paths = append(paths, filepath.Join(rootfsPath, "etc", "rc"+level+".d", "S99clicd-ipv6")) + } + for _, path := range paths { + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return err + } + } + return nil +} + func installContainerIPv6Systemd(rootfsPath string) error { servicePath := filepath.Join(rootfsPath, "etc", "systemd", "system", "clicd-ipv6.service") if err := os.MkdirAll(filepath.Dir(servicePath), 0755); err != nil { @@ -1873,6 +1961,21 @@ func containerIPv6ConnectivityOK(lxcName string) bool { return false } +func (m *Manager) removeGuestIPv6Addresses(lxcName string, assignments []config.IPv6Assignment) { + addrs := ipv6AssignmentAddresses(assignments) + if len(addrs) == 0 { + return + } + quoted := shellQuotedIPv6List(addrs) + _ = exec.Command("lxc-attach", "-n", lxcName, "--", "sh", "-c", + fmt.Sprintf("for ip in %s; do ip -6 addr del \"$ip/128\" dev eth0 2>/dev/null || true; done", quoted)).Run() +} + +func (m *Manager) removeGuestIPv6DefaultRoute(lxcName string) { + _ = exec.Command("lxc-attach", "-n", lxcName, "--", "sh", "-c", + fmt.Sprintf("ip -6 route del default via %s dev eth0 2>/dev/null || true", shellQuote(ipv6GatewayLinkLocal))).Run() +} + func ensureIPv6NAT66(ipv6, uplink string) { if ipv6 == "" || uplink == "" { return diff --git a/backend/internal/lxc/portmap.go b/backend/internal/lxc/portmap.go index 98d0288..c4f74cf 100644 --- a/backend/internal/lxc/portmap.go +++ b/backend/internal/lxc/portmap.go @@ -399,6 +399,64 @@ func persistAndReloadMappings(m *Manager, c *config.Container) error { return nil } +func (m *Manager) UpdatePublicIPv4Assignments(id int, requested []string, count int, auto bool) (*config.Container, error) { + c := config.FindContainer(id) + if c == nil { + return nil, fmt.Errorf("container not found: %d", id) + } + if c.UsesLANIPv4() { + return nil, fmt.Errorf("public IPv4 cannot be assigned while LAN IPv4 mode is enabled") + } + + assignments := []config.PublicIPv4Assignment{} + if auto || len(requested) > 0 { + allocated, err := AllocatePublicIPv4Assignments(id, requested, count, auto) + if err != nil { + return nil, err + } + assignments = allocated + } + + c.PublicIPv4s = assignments + reconcilePortMappingHostIPs(c) + c.NormalizeNetworkAssignments() + config.SaveConfig() + + _ = m.CleanPortMappings(id) + EnsureAssignedPublicIPv4s(c.PublicIPv4s) + if c.Status == "running" && c.IP != "" { + if err := m.ApplyPortMappings(id); err != nil { + return nil, err + } + } + return c, nil +} + +func reconcilePortMappingHostIPs(c *config.Container) { + if c == nil { + return + } + assigned := map[string]bool{} + for _, item := range c.PublicIPv4s { + if addr := strings.TrimSpace(item.Address); addr != "" { + assigned[addr] = true + } + } + replacement := "" + if len(assigned) == 1 { + for addr := range assigned { + replacement = addr + } + } + for i := range c.PortMappings { + hostIP := strings.TrimSpace(c.PortMappings[i].HostIP) + if hostIP == "" || assigned[hostIP] { + continue + } + c.PortMappings[i].HostIP = replacement + } +} + func normalizePortMapping(c *config.Container, skipIndex int, pm config.PortMapping) (config.PortMapping, error) { if pm.ContainerPort < 1 || pm.ContainerPort > 65535 { return pm, fmt.Errorf("container port must be 1-65535") diff --git a/frontend/src/components/CreateContainerModal.tsx b/frontend/src/components/CreateContainerModal.tsx index 2cf3e48..9473c78 100644 --- a/frontend/src/components/CreateContainerModal.tsx +++ b/frontend/src/components/CreateContainerModal.tsx @@ -131,7 +131,8 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist const natEnabled = form.assign_nat !== false && !lanIPv4Enabled const lanInterfaces = useMemo(() => getLANDHCPInterfaces(hostReport), [hostReport]) const defaultLANInterface = lanInterfaces[0]?.name || '' - const natPortCount = natEnabled ? Math.max(2, form.port_mapping_count || 2) : 0 + const customNATPorts = form.extra_ports || [] + const natPortCount = natEnabled ? Math.max(2, form.port_mapping_count || 2, customNATPorts.length + 1) : 0 const linuxTemplate = !isWindowsTemplate(form.template_id) const sshAuthMode = (form.ssh_auth_mode || 'auto_password') as SSHAuthMode @@ -140,6 +141,7 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist const count = natPortCount return Array.from({ length: count - 1 }, (_, index) => 22002 + index) }, [natEnabled, natPortCount]) + const natPreviewPorts = customNATPorts.length > 0 ? customNATPorts : autoPorts // SSH port preview (will be allocated sequentially, starting around 22000+) const sshPortPreview = 22000 @@ -213,11 +215,11 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist ...boundedForm, name, assign_nat: wantsNAT, - port_mapping_count: wantsNAT ? Math.max(2, boundedForm.port_mapping_count || 2) : 0, + port_mapping_count: wantsNAT ? Math.max(2, boundedForm.port_mapping_count || 2, (boundedForm.extra_ports || []).length + 1) : 0, snapshot_limit: Math.max(1, boundedForm.snapshot_limit || 3), ipv4_count: boundedForm.assign_ipv4 ? Math.max(1, boundedForm.ipv4_count || 1) : 0, ipv6_count: boundedForm.assign_ipv6 ? Math.max(1, boundedForm.ipv6_count || 1) : 0, - extra_ports: [], + extra_ports: wantsNAT ? (boundedForm.extra_ports || []) : [], }) } @@ -240,7 +242,7 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist return (
{resourceErrors.ram_mb}
}{resourceErrors.disk_gb}
}不选则长期有效
+不选择则长期有效;选择日期后,到期会自动关机。
-