mirror of
https://github.com/MengMengCode/CLICD.git
synced 2026-08-08 22:44:43 +08:00
FIX ##30
This commit is contained in:
@@ -172,6 +172,16 @@ func HandleSingleContainer(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
assignIPv6(w, r, id)
|
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/"):
|
case action == "snapshots" || strings.HasPrefix(action, "snapshots/"):
|
||||||
handleContainerSnapshots(w, r, id, action)
|
handleContainerSnapshots(w, r, id, action)
|
||||||
case action == "port-mappings" && r.Method == http.MethodPost:
|
case action == "port-mappings" && r.Method == http.MethodPost:
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import "net/http"
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
func HandleIPv6Status(w http.ResponseWriter, r *http.Request) {
|
func HandleIPv6Status(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodGet {
|
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})
|
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})
|
||||||
|
}
|
||||||
|
|||||||
@@ -115,6 +115,22 @@ func assignIPv6ByRuntime(id int) (*config.Container, error) {
|
|||||||
return lxcManager.AssignIPv6(id)
|
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) {
|
func usageByRuntime(id int) (map[string]interface{}, error) {
|
||||||
c := config.FindContainer(id)
|
c := config.FindContainer(id)
|
||||||
if c != nil && c.IsKVM() {
|
if c != nil && c.IsKVM() {
|
||||||
|
|||||||
@@ -506,13 +506,17 @@ func ensureSchemaMigrations() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if _, err := db.Exec(`UPDATE containers
|
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_prefix_len = COALESCE(lan_ipv4_prefix_len, 0),
|
||||||
lan_ipv4_gateway = COALESCE(lan_ipv4_gateway, '')`); err != nil {
|
lan_ipv4_gateway = COALESCE(lan_ipv4_gateway, '')`); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if _, err := db.Exec(`UPDATE tasks
|
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_prefix_len = COALESCE(cfg_lan_ipv4_prefix_len, 0),
|
||||||
cfg_lan_ipv4_gateway = COALESCE(cfg_lan_ipv4_gateway, '')`); err != nil {
|
cfg_lan_ipv4_gateway = COALESCE(cfg_lan_ipv4_gateway, '')`); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -975,6 +979,7 @@ func loadContainers() ([]Container, error) {
|
|||||||
var scheduleEnabled, policyBlocked, firewallEnabled, imageLimitConfigured int
|
var scheduleEnabled, policyBlocked, firewallEnabled, imageLimitConfigured int
|
||||||
var firewallDefaultAction string
|
var firewallDefaultAction string
|
||||||
var firewallRulesJSON, allowedImageIDs sql.NullString
|
var firewallRulesJSON, allowedImageIDs sql.NullString
|
||||||
|
var lanIPv4Mode, lanInterface sql.NullString
|
||||||
var lanIPv4Address, lanIPv4Gateway sql.NullString
|
var lanIPv4Address, lanIPv4Gateway sql.NullString
|
||||||
var lanIPv4PrefixLen sql.NullInt64
|
var lanIPv4PrefixLen sql.NullInt64
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
@@ -983,7 +988,7 @@ func loadContainers() ([]Container, error) {
|
|||||||
&c.MonthlyTrafficGB, &c.TrafficMode, &c.TrafficInGB,
|
&c.MonthlyTrafficGB, &c.TrafficMode, &c.TrafficInGB,
|
||||||
&c.TrafficOutGB, &c.TrafficUsedRX, &c.TrafficUsedTX, &c.TrafficResetDate,
|
&c.TrafficOutGB, &c.TrafficUsedRX, &c.TrafficUsedTX, &c.TrafficResetDate,
|
||||||
&c.IOSpeedMBps, &c.IOReadMBps, &c.IOWriteMBps,
|
&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.IPv6, &c.IPv6PrefixLen, &c.IPv6Interface, &c.VNCPort, &c.SSHPort, &c.SSHPassword,
|
||||||
&c.SSHHostKey, &c.PortMappingLimit, &c.SnapshotLimit, &c.CreatedAt, &c.ExpiresAt,
|
&c.SSHHostKey, &c.PortMappingLimit, &c.SnapshotLimit, &c.CreatedAt, &c.ExpiresAt,
|
||||||
&scheduleEnabled, &c.SnapshotScheduleIntervalHours, &c.SnapshotScheduleTime,
|
&scheduleEnabled, &c.SnapshotScheduleIntervalHours, &c.SnapshotScheduleTime,
|
||||||
@@ -993,6 +998,8 @@ func loadContainers() ([]Container, error) {
|
|||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
c.LANIPv4Mode = lanIPv4Mode.String
|
||||||
|
c.LANInterface = lanInterface.String
|
||||||
c.LANIPv4Address = lanIPv4Address.String
|
c.LANIPv4Address = lanIPv4Address.String
|
||||||
if lanIPv4PrefixLen.Valid {
|
if lanIPv4PrefixLen.Valid {
|
||||||
c.LANIPv4PrefixLen = int(lanIPv4PrefixLen.Int64)
|
c.LANIPv4PrefixLen = int(lanIPv4PrefixLen.Int64)
|
||||||
|
|||||||
@@ -3363,6 +3363,109 @@ func (m *Manager) AssignIPv6(id int) (*config.Container, error) {
|
|||||||
return c, nil
|
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 {
|
func (m *Manager) applyIPv6Runtime(c *config.Container) error {
|
||||||
if c == nil || (c.IPv6 == "" && len(c.IPv6Addresses) == 0) {
|
if c == nil || (c.IPv6 == "" && len(c.IPv6Addresses) == 0) {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -1515,6 +1515,75 @@ func (m *Manager) AssignIPv6(id int) (*config.Container, error) {
|
|||||||
return c, nil
|
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 {
|
func (m *Manager) applyIPv6Config(lxcName string, ipv6s ...string) error {
|
||||||
configFile := filepath.Join(m.LxcPath, lxcName, "config")
|
configFile := filepath.Join(m.LxcPath, lxcName, "config")
|
||||||
data, err := os.ReadFile(configFile)
|
data, err := os.ReadFile(configFile)
|
||||||
@@ -1705,6 +1774,25 @@ exit 0
|
|||||||
return nil
|
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 {
|
func installContainerIPv6Systemd(rootfsPath string) error {
|
||||||
servicePath := filepath.Join(rootfsPath, "etc", "systemd", "system", "clicd-ipv6.service")
|
servicePath := filepath.Join(rootfsPath, "etc", "systemd", "system", "clicd-ipv6.service")
|
||||||
if err := os.MkdirAll(filepath.Dir(servicePath), 0755); err != nil {
|
if err := os.MkdirAll(filepath.Dir(servicePath), 0755); err != nil {
|
||||||
@@ -1873,6 +1961,21 @@ func containerIPv6ConnectivityOK(lxcName string) bool {
|
|||||||
return false
|
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) {
|
func ensureIPv6NAT66(ipv6, uplink string) {
|
||||||
if ipv6 == "" || uplink == "" {
|
if ipv6 == "" || uplink == "" {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -399,6 +399,64 @@ func persistAndReloadMappings(m *Manager, c *config.Container) error {
|
|||||||
return nil
|
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) {
|
func normalizePortMapping(c *config.Container, skipIndex int, pm config.PortMapping) (config.PortMapping, error) {
|
||||||
if pm.ContainerPort < 1 || pm.ContainerPort > 65535 {
|
if pm.ContainerPort < 1 || pm.ContainerPort > 65535 {
|
||||||
return pm, fmt.Errorf("container port must be 1-65535")
|
return pm, fmt.Errorf("container port must be 1-65535")
|
||||||
|
|||||||
@@ -131,7 +131,8 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
|||||||
const natEnabled = form.assign_nat !== false && !lanIPv4Enabled
|
const natEnabled = form.assign_nat !== false && !lanIPv4Enabled
|
||||||
const lanInterfaces = useMemo(() => getLANDHCPInterfaces(hostReport), [hostReport])
|
const lanInterfaces = useMemo(() => getLANDHCPInterfaces(hostReport), [hostReport])
|
||||||
const defaultLANInterface = lanInterfaces[0]?.name || ''
|
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 linuxTemplate = !isWindowsTemplate(form.template_id)
|
||||||
const sshAuthMode = (form.ssh_auth_mode || 'auto_password') as SSHAuthMode
|
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
|
const count = natPortCount
|
||||||
return Array.from({ length: count - 1 }, (_, index) => 22002 + index)
|
return Array.from({ length: count - 1 }, (_, index) => 22002 + index)
|
||||||
}, [natEnabled, natPortCount])
|
}, [natEnabled, natPortCount])
|
||||||
|
const natPreviewPorts = customNATPorts.length > 0 ? customNATPorts : autoPorts
|
||||||
|
|
||||||
// SSH port preview (will be allocated sequentially, starting around 22000+)
|
// SSH port preview (will be allocated sequentially, starting around 22000+)
|
||||||
const sshPortPreview = 22000
|
const sshPortPreview = 22000
|
||||||
@@ -213,11 +215,11 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
|||||||
...boundedForm,
|
...boundedForm,
|
||||||
name,
|
name,
|
||||||
assign_nat: wantsNAT,
|
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),
|
snapshot_limit: Math.max(1, boundedForm.snapshot_limit || 3),
|
||||||
ipv4_count: boundedForm.assign_ipv4 ? Math.max(1, boundedForm.ipv4_count || 1) : 0,
|
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,
|
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 (
|
return (
|
||||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||||
<div className="bg-white rounded-lg border border-gray-200 shadow-xl w-full max-w-2xl max-h-[90vh] overflow-y-auto">
|
<div className="bg-white rounded-lg border border-gray-200 shadow-xl w-full max-w-3xl max-h-[92vh] overflow-y-auto">
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
|
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
|
||||||
<h2 className="text-lg font-semibold text-black">创建新容器</h2>
|
<h2 className="text-lg font-semibold text-black">创建新容器</h2>
|
||||||
<button onClick={onClose} className="p-1 hover:bg-gray-100 rounded text-gray-500" title="关闭">
|
<button onClick={onClose} className="p-1 hover:bg-gray-100 rounded text-gray-500" title="关闭">
|
||||||
@@ -248,8 +250,8 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-6 py-4 space-y-4">
|
<div className="px-5 py-4 space-y-3">
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<Field label="容器名称">
|
<Field label="容器名称">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -399,6 +401,7 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className="grid gap-3 lg:grid-cols-2">
|
||||||
<div className={`rounded-md border px-3 py-2 text-sm ${ipv4Available ? 'border-gray-200 bg-white' : 'border-gray-200 bg-gray-50 text-gray-400'}`}>
|
<div className={`rounded-md border px-3 py-2 text-sm ${ipv4Available ? 'border-gray-200 bg-white' : 'border-gray-200 bg-gray-50 text-gray-400'}`}>
|
||||||
<label className="flex items-start gap-3">
|
<label className="flex items-start gap-3">
|
||||||
<input
|
<input
|
||||||
@@ -595,6 +598,39 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{form.assign_ipv6 && (
|
||||||
|
<div className="mt-3 space-y-3 pl-6">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<label className="flex items-center gap-2 text-xs text-gray-600">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
checked={(form.ipv6_addresses || []).length === 0}
|
||||||
|
onChange={() => setForm({ ...form, ipv6_addresses: [] })}
|
||||||
|
/>
|
||||||
|
Random assign
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 text-xs text-gray-600">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
checked={(form.ipv6_addresses || []).length > 0}
|
||||||
|
onChange={() => setForm({ ...form, ipv6_addresses: [''], ipv6_count: 1 })}
|
||||||
|
/>
|
||||||
|
Custom assign
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{(form.ipv6_addresses || []).length > 0 && (
|
||||||
|
<textarea
|
||||||
|
value={(form.ipv6_addresses || []).join('\n')}
|
||||||
|
onChange={(event) => {
|
||||||
|
const next = splitAddressLines(event.target.value)
|
||||||
|
setForm({ ...form, ipv6_addresses: next.length ? next : [''], ipv6_count: Math.max(1, next.length || 1) })
|
||||||
|
}}
|
||||||
|
className={`${inputClass} min-h-20 font-mono text-xs`}
|
||||||
|
placeholder="2001:db8:100::100"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-md border border-gray-200 bg-white px-3 py-2 text-sm">
|
<div className="rounded-md border border-gray-200 bg-white px-3 py-2 text-sm">
|
||||||
@@ -622,25 +658,57 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
|||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
{natEnabled && (
|
{natEnabled && customNATPorts.length === 0 && (
|
||||||
<span className="block w-24 shrink-0">
|
<span className="block w-24 shrink-0">
|
||||||
<NumberInput
|
<NumberInput
|
||||||
value={natPortCount}
|
value={natPortCount}
|
||||||
min={2}
|
min={2}
|
||||||
max={64}
|
max={64}
|
||||||
onChange={(value) => setForm({ ...form, port_mapping_count: Math.max(2, value || 2), assign_nat: true })}
|
onChange={(value) => setForm({ ...form, port_mapping_count: Math.max(2, value || 2), assign_nat: true, extra_ports: [] })}
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{natEnabled && (
|
{natEnabled && (
|
||||||
<div className="mt-2 pl-6">
|
<div className="mt-2 space-y-2 pl-6">
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<label className="flex items-center gap-2 text-xs text-gray-600">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
checked={customNATPorts.length === 0}
|
||||||
|
onChange={() => setForm({ ...form, extra_ports: [], port_mapping_count: Math.max(2, form.port_mapping_count || 2) })}
|
||||||
|
/>
|
||||||
|
Auto ports
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 text-xs text-gray-600">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
checked={customNATPorts.length > 0}
|
||||||
|
onChange={() => {
|
||||||
|
const next = customNATPorts.length > 0 ? customNATPorts : [22002]
|
||||||
|
setForm({ ...form, extra_ports: next, port_mapping_count: Math.max(2, next.length + 1), assign_nat: true })
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
Custom ports
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{customNATPorts.length > 0 && (
|
||||||
|
<textarea
|
||||||
|
value={customNATPorts.join('\n')}
|
||||||
|
onChange={(event) => {
|
||||||
|
const next = parsePortList(event.target.value)
|
||||||
|
setForm({ ...form, extra_ports: next, port_mapping_count: Math.max(2, next.length + 1), assign_nat: true })
|
||||||
|
}}
|
||||||
|
className={`${inputClass} min-h-16 font-mono text-xs`}
|
||||||
|
placeholder={'22002\n8080\n8443'}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
<span className="inline-flex px-2 py-1 bg-emerald-50 text-emerald-700 rounded text-xs font-mono">
|
<span className="inline-flex px-2 py-1 bg-emerald-50 text-emerald-700 rounded text-xs font-mono">
|
||||||
{isWindowsTemplate(form.template_id) ? 'RDP' : 'SSH'}: {sshPortPreview} -> {isWindowsTemplate(form.template_id) ? 3389 : 22}
|
{isWindowsTemplate(form.template_id) ? 'RDP' : 'SSH'}: {sshPortPreview} -> {isWindowsTemplate(form.template_id) ? 3389 : 22}
|
||||||
</span>
|
</span>
|
||||||
{autoPorts.map((port) => (
|
{natPreviewPorts.map((port, index) => (
|
||||||
<span key={port} className="inline-flex px-2 py-1 bg-gray-100 text-gray-700 rounded text-xs font-mono">
|
<span key={`${port}-${index}`} className="inline-flex px-2 py-1 bg-gray-100 text-gray-700 rounded text-xs font-mono">
|
||||||
{port} -> {port}
|
{port} -> {port}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
@@ -648,8 +716,9 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||||
<Field label="vCPU">
|
<Field label="vCPU">
|
||||||
<NumberInput
|
<NumberInput
|
||||||
value={form.vcpu}
|
value={form.vcpu}
|
||||||
@@ -672,9 +741,6 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
|||||||
/>
|
/>
|
||||||
{resourceErrors.ram_mb && <p className="mt-1 text-xs text-red-500">{resourceErrors.ram_mb}</p>}
|
{resourceErrors.ram_mb && <p className="mt-1 text-xs text-red-500">{resourceErrors.ram_mb}</p>}
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
|
|
||||||
<Field label="磁盘 (GB)">
|
<Field label="磁盘 (GB)">
|
||||||
<NumberInput
|
<NumberInput
|
||||||
value={form.disk_gb}
|
value={form.disk_gb}
|
||||||
@@ -685,26 +751,20 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
|||||||
/>
|
/>
|
||||||
{resourceErrors.disk_gb && <p className="mt-1 text-xs text-red-500">{resourceErrors.disk_gb}</p>}
|
{resourceErrors.disk_gb && <p className="mt-1 text-xs text-red-500">{resourceErrors.disk_gb}</p>}
|
||||||
</Field>
|
</Field>
|
||||||
<div className="grid grid-cols-2 gap-3 md:col-span-2">
|
<Field label="下行带宽 (Mbps)">
|
||||||
<Field label="下行带宽 (Mbps)">
|
<NumberInput value={form.network_down_mbps} min={0} onChange={(value) => setForm({ ...form, network_down_mbps: value, network_bw_mbps: symmetricLimit(value, form.network_up_mbps) })} />
|
||||||
<NumberInput value={form.network_down_mbps} min={0} onChange={(value) => setForm({ ...form, network_down_mbps: value, network_bw_mbps: symmetricLimit(value, form.network_up_mbps) })} />
|
</Field>
|
||||||
</Field>
|
<Field label="上行带宽 (Mbps)">
|
||||||
<Field label="上行带宽 (Mbps)">
|
<NumberInput value={form.network_up_mbps} min={0} onChange={(value) => setForm({ ...form, network_up_mbps: value, network_bw_mbps: symmetricLimit(form.network_down_mbps, value) })} />
|
||||||
<NumberInput value={form.network_up_mbps} min={0} onChange={(value) => setForm({ ...form, network_up_mbps: value, network_bw_mbps: symmetricLimit(form.network_down_mbps, value) })} />
|
</Field>
|
||||||
</Field>
|
<Field label="读取 IO (MB/s)">
|
||||||
<Field label="读取 IO (MB/s)">
|
<NumberInput value={form.io_read_mbps} min={0} onChange={(value) => setForm({ ...form, io_read_mbps: value, io_speed_mbps: symmetricLimit(value, form.io_write_mbps) })} />
|
||||||
<NumberInput value={form.io_read_mbps} min={0} onChange={(value) => setForm({ ...form, io_read_mbps: value, io_speed_mbps: symmetricLimit(value, form.io_write_mbps) })} />
|
</Field>
|
||||||
</Field>
|
<Field label="写入 IO (MB/s)">
|
||||||
<Field label="写入 IO (MB/s)">
|
<NumberInput value={form.io_write_mbps} min={0} onChange={(value) => setForm({ ...form, io_write_mbps: value, io_speed_mbps: symmetricLimit(form.io_read_mbps, value) })} />
|
||||||
<NumberInput value={form.io_write_mbps} min={0} onChange={(value) => setForm({ ...form, io_write_mbps: value, io_speed_mbps: symmetricLimit(form.io_read_mbps, value) })} />
|
</Field>
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
||||||
{/* Traffic control */}
|
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-3 mb-2">
|
<div className="mb-1.5 flex items-center justify-between gap-2">
|
||||||
<label className="text-sm font-medium text-gray-700">月流量</label>
|
<label className="text-sm font-medium text-gray-700">月流量</label>
|
||||||
<select
|
<select
|
||||||
value={form.traffic_mode}
|
value={form.traffic_mode}
|
||||||
@@ -718,10 +778,10 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
|||||||
{form.traffic_mode === 'total' ? (
|
{form.traffic_mode === 'total' ? (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<NumberInput value={form.monthly_traffic_gb} min={0} onChange={(value) => setForm({ ...form, monthly_traffic_gb: value })} />
|
<NumberInput value={form.monthly_traffic_gb} min={0} onChange={(value) => setForm({ ...form, monthly_traffic_gb: value })} />
|
||||||
<span className="text-xs text-gray-400">GB (0=不限制)</span>
|
<span className="shrink-0 text-xs text-gray-400">GB</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<Field label="入站 (GB)">
|
<Field label="入站 (GB)">
|
||||||
<NumberInput value={form.traffic_in_gb} min={0} onChange={(value) => setForm({ ...form, traffic_in_gb: value || 0 })} />
|
<NumberInput value={form.traffic_in_gb} min={0} onChange={(value) => setForm({ ...form, traffic_in_gb: value || 0 })} />
|
||||||
</Field>
|
</Field>
|
||||||
@@ -731,7 +791,6 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Field label="子用户快照上限">
|
<Field label="子用户快照上限">
|
||||||
<NumberInput
|
<NumberInput
|
||||||
value={form.snapshot_limit}
|
value={form.snapshot_limit}
|
||||||
@@ -740,21 +799,20 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
|||||||
onChange={(value) => setForm({ ...form, snapshot_limit: Math.max(1, Math.round(value || 1)) })}
|
onChange={(value) => setForm({ ...form, snapshot_limit: Math.max(1, Math.round(value || 1)) })}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
<Field label="到期时间">
|
||||||
|
<div className="relative">
|
||||||
|
<CalendarClock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={form.expires_at}
|
||||||
|
onChange={(event) => setForm({ ...form, expires_at: event.target.value })}
|
||||||
|
min={new Date().toISOString().slice(0, 10)}
|
||||||
|
className={`${inputClass} pl-10`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-[11px] leading-4 text-gray-400">不选则长期有效</p>
|
||||||
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Field label="到期时间">
|
|
||||||
<div className="relative">
|
|
||||||
<CalendarClock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={form.expires_at}
|
|
||||||
onChange={(event) => setForm({ ...form, expires_at: event.target.value })}
|
|
||||||
min={new Date().toISOString().slice(0, 10)}
|
|
||||||
className={`${inputClass} pl-10`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-gray-400 mt-1.5">不选择则长期有效;选择日期后,到期会自动关机。</p>
|
|
||||||
</Field>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-gray-200">
|
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-gray-200">
|
||||||
@@ -877,6 +935,8 @@ function normalizeCreateForm(form: CreateContainerRequest): CreateContainerReque
|
|||||||
const wantsIPv6 = !!normalized.assign_ipv6
|
const wantsIPv6 = !!normalized.assign_ipv6
|
||||||
// IPv4 and NAT are mutually exclusive
|
// IPv4 and NAT are mutually exclusive
|
||||||
const wantsNAT = wantsLANIPv4 || wantsIPv4 ? false : normalized.assign_nat !== false
|
const wantsNAT = wantsLANIPv4 || wantsIPv4 ? false : normalized.assign_nat !== false
|
||||||
|
const extraPorts = wantsNAT ? normalizePortList(normalized.extra_ports || []) : []
|
||||||
|
const portMappingCount = wantsNAT ? clampInt(Math.max(normalized.port_mapping_count || 2, extraPorts.length + 1), 2, 64, 2) : 0
|
||||||
const linuxTemplate = !isWindowsTemplate(normalized.template_id)
|
const linuxTemplate = !isWindowsTemplate(normalized.template_id)
|
||||||
const sshAuthMode = linuxTemplate ? (normalized.ssh_auth_mode || 'auto_password') : 'auto_password'
|
const sshAuthMode = linuxTemplate ? (normalized.ssh_auth_mode || 'auto_password') : 'auto_password'
|
||||||
return {
|
return {
|
||||||
@@ -885,7 +945,8 @@ function normalizeCreateForm(form: CreateContainerRequest): CreateContainerReque
|
|||||||
ram_mb: Math.round(normalized.ram_mb),
|
ram_mb: Math.round(normalized.ram_mb),
|
||||||
disk_gb: Math.round(normalized.disk_gb),
|
disk_gb: Math.round(normalized.disk_gb),
|
||||||
assign_nat: wantsNAT,
|
assign_nat: wantsNAT,
|
||||||
port_mapping_count: wantsNAT ? clampInt(normalized.port_mapping_count, 2, 64, 2) : 0,
|
port_mapping_count: portMappingCount,
|
||||||
|
extra_ports: extraPorts,
|
||||||
lan_ipv4_mode: wantsLANDHCP ? 'dhcp' : (wantsLANStatic ? 'static' : ''),
|
lan_ipv4_mode: wantsLANDHCP ? 'dhcp' : (wantsLANStatic ? 'static' : ''),
|
||||||
lan_interface: wantsLANIPv4 ? (normalized.lan_interface || '').trim() : '',
|
lan_interface: wantsLANIPv4 ? (normalized.lan_interface || '').trim() : '',
|
||||||
lan_ipv4_address: wantsLANStatic ? (normalized.lan_ipv4_address || '').trim() : '',
|
lan_ipv4_address: wantsLANStatic ? (normalized.lan_ipv4_address || '').trim() : '',
|
||||||
@@ -896,7 +957,7 @@ function normalizeCreateForm(form: CreateContainerRequest): CreateContainerReque
|
|||||||
public_ipv4s: wantsIPv4 ? (normalized.public_ipv4s || []) : [],
|
public_ipv4s: wantsIPv4 ? (normalized.public_ipv4s || []) : [],
|
||||||
assign_ipv6: wantsIPv6,
|
assign_ipv6: wantsIPv6,
|
||||||
ipv6_count: wantsIPv6 ? clampInt(normalized.ipv6_count || 1, 1, 64, 1) : 0,
|
ipv6_count: wantsIPv6 ? clampInt(normalized.ipv6_count || 1, 1, 64, 1) : 0,
|
||||||
ipv6_addresses: wantsIPv6 ? (normalized.ipv6_addresses || []) : [],
|
ipv6_addresses: wantsIPv6 ? (normalized.ipv6_addresses || []).map((item) => item.trim()).filter(Boolean) : [],
|
||||||
ssh_auth_mode: sshAuthMode,
|
ssh_auth_mode: sshAuthMode,
|
||||||
ssh_password: linuxTemplate && sshAuthMode === 'password' ? (normalized.ssh_password || '').trim() : '',
|
ssh_password: linuxTemplate && sshAuthMode === 'password' ? (normalized.ssh_password || '').trim() : '',
|
||||||
ssh_public_key: linuxTemplate && sshAuthMode === 'key' ? (normalized.ssh_public_key || '').trim() : '',
|
ssh_public_key: linuxTemplate && sshAuthMode === 'key' ? (normalized.ssh_public_key || '').trim() : '',
|
||||||
@@ -948,6 +1009,28 @@ function clampInt(value: number, min: number, max?: number, fallback = min) {
|
|||||||
return Math.min(Math.max(next, min), max ?? next)
|
return Math.min(Math.max(next, min), max ?? next)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parsePortList(value: string) {
|
||||||
|
return normalizePortList(
|
||||||
|
value
|
||||||
|
.split(/[\s,,;;]+/)
|
||||||
|
.map((item) => Number(item.trim()))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePortList(ports: number[]) {
|
||||||
|
const seen = new Set<number>()
|
||||||
|
const result: number[] = []
|
||||||
|
for (const port of ports) {
|
||||||
|
if (!Number.isFinite(port)) continue
|
||||||
|
const next = Math.round(port)
|
||||||
|
if (next < 1 || next > 65535 || seen.has(next)) continue
|
||||||
|
seen.add(next)
|
||||||
|
result.push(next)
|
||||||
|
if (result.length >= 63) break
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
function isIPv4Address(value: string) {
|
function isIPv4Address(value: string) {
|
||||||
const parts = value.trim().split('.')
|
const parts = value.trim().split('.')
|
||||||
return parts.length === 4 && parts.every((part) => {
|
return parts.length === 4 && parts.every((part) => {
|
||||||
@@ -957,6 +1040,13 @@ function isIPv4Address(value: string) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function splitAddressLines(value: string) {
|
||||||
|
return value
|
||||||
|
.split(/[\n,,\s]+/)
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
function subnetMaskFromPrefixLen(prefixLen: number) {
|
function subnetMaskFromPrefixLen(prefixLen: number) {
|
||||||
if (!Number.isFinite(prefixLen) || prefixLen < 0 || prefixLen > 32) return '255.255.255.0'
|
if (!Number.isFinite(prefixLen) || prefixLen < 0 || prefixLen > 32) return '255.255.255.0'
|
||||||
const mask = prefixLen === 0 ? 0 : (0xffffffff << (32 - prefixLen)) >>> 0
|
const mask = prefixLen === 0 ? 0 : (0xffffffff << (32 - prefixLen)) >>> 0
|
||||||
|
|||||||
@@ -50,7 +50,10 @@ import {
|
|||||||
getEnabledImages,
|
getEnabledImages,
|
||||||
getFirewall,
|
getFirewall,
|
||||||
PortMapping,
|
PortMapping,
|
||||||
|
PublicIPv4Info,
|
||||||
FirewallRule,
|
FirewallRule,
|
||||||
|
updatePublicIPv4Assignments,
|
||||||
|
updateIPv6Assignments,
|
||||||
reinstallContainer,
|
reinstallContainer,
|
||||||
resetSSHPassword,
|
resetSSHPassword,
|
||||||
restartContainer,
|
restartContainer,
|
||||||
@@ -107,6 +110,8 @@ type MappingDraft = {
|
|||||||
protocol: string
|
protocol: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type IPAssignMode = 'clear' | 'random' | 'custom'
|
||||||
|
|
||||||
const emptyDraft: MappingDraft = {
|
const emptyDraft: MappingDraft = {
|
||||||
index: null,
|
index: null,
|
||||||
description: '',
|
description: '',
|
||||||
@@ -135,6 +140,14 @@ export default function ContainerDetail() {
|
|||||||
const vncFullscreenRef = useRef<HTMLDivElement>(null)
|
const vncFullscreenRef = useRef<HTMLDivElement>(null)
|
||||||
const [vncFullscreen, setVncFullscreen] = useState(false)
|
const [vncFullscreen, setVncFullscreen] = useState(false)
|
||||||
const [showNat, setShowNat] = useState(false)
|
const [showNat, setShowNat] = useState(false)
|
||||||
|
const [showIPAssign, setShowIPAssign] = useState(false)
|
||||||
|
const [savingIPAssign, setSavingIPAssign] = useState(false)
|
||||||
|
const [ipv4AssignMode, setIPv4AssignMode] = useState<IPAssignMode>('clear')
|
||||||
|
const [ipv4AssignCount, setIPv4AssignCount] = useState(1)
|
||||||
|
const [ipv4Selected, setIPv4Selected] = useState<string[]>([])
|
||||||
|
const [ipv6AssignMode, setIPv6AssignMode] = useState<IPAssignMode>('clear')
|
||||||
|
const [ipv6AssignCount, setIPv6AssignCount] = useState(1)
|
||||||
|
const [ipv6DraftText, setIPv6DraftText] = useState('')
|
||||||
const [showMappingEditor, setShowMappingEditor] = useState(false)
|
const [showMappingEditor, setShowMappingEditor] = useState(false)
|
||||||
const [showExpiryEdit, setShowExpiryEdit] = useState(false)
|
const [showExpiryEdit, setShowExpiryEdit] = useState(false)
|
||||||
const [editExpiry, setEditExpiry] = useState('')
|
const [editExpiry, setEditExpiry] = useState('')
|
||||||
@@ -630,6 +643,42 @@ export default function ContainerDetail() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const openIPAssign = () => {
|
||||||
|
const currentIPv4 = (container?.public_ipv4s || []).map((item) => item.address).filter(Boolean)
|
||||||
|
const currentIPv6 = (container?.ipv6_addresses || []).map((item) => item.address).filter(Boolean)
|
||||||
|
setIPv4Selected(currentIPv4)
|
||||||
|
setIPv4AssignMode(currentIPv4.length > 0 ? 'custom' : 'clear')
|
||||||
|
setIPv4AssignCount(Math.max(1, currentIPv4.length || 1))
|
||||||
|
setIPv6DraftText(currentIPv6.join('\n'))
|
||||||
|
setIPv6AssignMode(currentIPv6.length > 0 ? 'custom' : 'clear')
|
||||||
|
setIPv6AssignCount(Math.max(1, currentIPv6.length || 1))
|
||||||
|
setShowIPAssign(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitIPAssign = async () => {
|
||||||
|
if (!containerIdentifier) return
|
||||||
|
setSavingIPAssign(true)
|
||||||
|
try {
|
||||||
|
await updatePublicIPv4Assignments(containerIdentifier, {
|
||||||
|
mode: ipv4AssignMode,
|
||||||
|
count: Math.max(1, Math.round(ipv4AssignCount || 1)),
|
||||||
|
addresses: ipv4AssignMode === 'custom' ? ipv4Selected : [],
|
||||||
|
})
|
||||||
|
await updateIPv6Assignments(containerIdentifier, {
|
||||||
|
mode: ipv6AssignMode,
|
||||||
|
count: Math.max(1, Math.round(ipv6AssignCount || 1)),
|
||||||
|
addresses: ipv6AssignMode === 'custom' ? splitAddressLines(ipv6DraftText) : [],
|
||||||
|
})
|
||||||
|
await fetchContainer()
|
||||||
|
setShowIPAssign(false)
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const error = err as { response?: { data?: { message?: string } } }
|
||||||
|
dialog.alert('公网 IP 分配失败', error.response?.data?.message || '请检查地址是否可用或已被占用')
|
||||||
|
} finally {
|
||||||
|
setSavingIPAssign(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const openAddMapping = () => {
|
const openAddMapping = () => {
|
||||||
if (isSubUser && container?.policy_blocked) return
|
if (isSubUser && container?.policy_blocked) return
|
||||||
setDraft(emptyDraft)
|
setDraft(emptyDraft)
|
||||||
@@ -864,6 +913,7 @@ export default function ContainerDetail() {
|
|||||||
const policyBlockedText = container.policy_blocked_reason || '虚拟机被策略临时封禁'
|
const policyBlockedText = container.policy_blocked_reason || '虚拟机被策略临时封禁'
|
||||||
const publicIPv4s = container.public_ipv4s || []
|
const publicIPv4s = container.public_ipv4s || []
|
||||||
const assignedIPv4List = publicIPv4s.map((item) => item.address).filter(Boolean)
|
const assignedIPv4List = publicIPv4s.map((item) => item.address).filter(Boolean)
|
||||||
|
const allocatableIPv4s = mergeIPv4Choices(hostInfo?.network.public_ipv4_addresses || [], publicIPv4s)
|
||||||
const publicHost = assignedIPv4List[0] || hostInfo?.network.public_ipv4 || PUBLIC_HOST
|
const publicHost = assignedIPv4List[0] || hostInfo?.network.public_ipv4 || PUBLIC_HOST
|
||||||
const ipv6List = (container.ipv6_addresses || [])
|
const ipv6List = (container.ipv6_addresses || [])
|
||||||
.map((item) => item.address)
|
.map((item) => item.address)
|
||||||
@@ -1189,14 +1239,27 @@ export default function ContainerDetail() {
|
|||||||
<PlainRow label="识别码" value={container.uuid || '-'} mono copyValue={container.uuid} onCopy={copyText} />
|
<PlainRow label="识别码" value={container.uuid || '-'} mono copyValue={container.uuid} onCopy={copyText} />
|
||||||
<PlainRow label="状态" value={isRunning ? '运行中' : '已停止'} />
|
<PlainRow label="状态" value={isRunning ? '运行中' : '已停止'} />
|
||||||
<PlainRow label="内网 IP" value={container.ip || '-'} mono />
|
<PlainRow label="内网 IP" value={container.ip || '-'} mono />
|
||||||
<PlainRow label="Public IPv4" value={assignedIPv4List.length ? assignedIPv4List.join(', ') : '-'} mono copyValue={assignedIPv4List[0]} onCopy={copyText} />
|
<PlainRow label="Public IPv4" value={assignedIPv4List.length ? assignedIPv4List.join(', ') : '-'} mono copyValue={assignedIPv4List[0]} onCopy={copyText}>
|
||||||
<PlainRow label="IPv6" value={ipv6List.length ? ipv6List.join(', ') : '-'} mono copyValue={ipv6List[0]} onCopy={copyText}>
|
{!isSubUser && (
|
||||||
{!isSubUser && ipv6List.length === 0 && (
|
<button onClick={openIPAssign} className="ml-1 p-0.5 text-gray-400 hover:text-black rounded" title="修改公网 IP 分配">
|
||||||
<button onClick={handleAssignIPv6} disabled={actionLoading === 'ipv6'} className="ml-1 px-1.5 py-0.5 text-[10px] text-gray-600 border border-gray-200 rounded hover:bg-gray-50 disabled:opacity-50">
|
<Pencil className="w-3 h-3" />
|
||||||
Assign
|
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</PlainRow>
|
</PlainRow>
|
||||||
|
<PlainRow label="IPv6" value={ipv6List.length ? ipv6List.join(', ') : '-'} mono copyValue={ipv6List[0]} onCopy={copyText}>
|
||||||
|
{!isSubUser && (
|
||||||
|
<>
|
||||||
|
{ipv6List.length === 0 && (
|
||||||
|
<button onClick={handleAssignIPv6} disabled={actionLoading === 'ipv6'} className="ml-1 px-1.5 py-0.5 text-[10px] text-gray-600 border border-gray-200 rounded hover:bg-gray-50 disabled:opacity-50">
|
||||||
|
Assign
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button onClick={openIPAssign} className="ml-1 p-0.5 text-gray-400 hover:text-black rounded" title="修改公网 IP 分配">
|
||||||
|
<Pencil className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</PlainRow>
|
||||||
<PlainRow label="CPU 累计时间" value={formatCPU(usage?.cpu_usage_usec || 0)} />
|
<PlainRow label="CPU 累计时间" value={formatCPU(usage?.cpu_usage_usec || 0)} />
|
||||||
<PlainRow label="创建时间" value={container.created_at} />
|
<PlainRow label="创建时间" value={container.created_at} />
|
||||||
<PlainRow label="到期时间" value={formatExpiration(container.expires_at)}>
|
<PlainRow label="到期时间" value={formatExpiration(container.expires_at)}>
|
||||||
@@ -1817,6 +1880,88 @@ export default function ContainerDetail() {
|
|||||||
</Modal>
|
</Modal>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showIPAssign && (
|
||||||
|
<Modal title="公网 IP 分配" onClose={() => setShowIPAssign(false)} wide>
|
||||||
|
<div className="grid gap-5 md:grid-cols-2">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium text-gray-900">独立 IPv4</h3>
|
||||||
|
<p className="mt-1 text-xs text-gray-500">修改后会重放端口映射、SNAT 和防火墙规则。</p>
|
||||||
|
</div>
|
||||||
|
<Segmented value={ipv4AssignMode} onChange={setIPv4AssignMode} />
|
||||||
|
{ipv4AssignMode === 'random' && (
|
||||||
|
<Field label="随机数量">
|
||||||
|
<input type="number" min={1} max={64} value={ipv4AssignCount} onChange={(e) => setIPv4AssignCount(parseInt(e.target.value || '1', 10))} className={inputClass} />
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
{ipv4AssignMode === 'custom' && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{allocatableIPv4s.length === 0 ? (
|
||||||
|
<div className="rounded-md border border-gray-200 bg-gray-50 px-3 py-2 text-xs text-gray-500">没有可选择的公网 IPv4,请先到路由管理配置 IPv4 池。</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-2">
|
||||||
|
{allocatableIPv4s.map((ip) => (
|
||||||
|
<label key={`${ip.interface}-${ip.address}`} className="flex min-w-0 items-center gap-2 rounded-md border border-gray-200 px-3 py-2 text-xs text-gray-700">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={ipv4Selected.includes(ip.address)}
|
||||||
|
onChange={(event) => {
|
||||||
|
const next = event.target.checked
|
||||||
|
? Array.from(new Set([...ipv4Selected, ip.address]))
|
||||||
|
: ipv4Selected.filter((value) => value !== ip.address)
|
||||||
|
setIPv4Selected(next)
|
||||||
|
setIPv4AssignCount(Math.max(1, next.length || 1))
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="truncate font-mono">{ip.address}</span>
|
||||||
|
<span className="shrink-0 text-gray-400">{ip.interface}</span>
|
||||||
|
{ip.gateway && <span className="shrink-0 text-gray-400">gw {ip.gateway}</span>}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium text-gray-900">独立 IPv6</h3>
|
||||||
|
<p className="mt-1 text-xs text-gray-500">自定义地址必须落在路由管理配置的 IPv6 前缀内。</p>
|
||||||
|
</div>
|
||||||
|
<Segmented value={ipv6AssignMode} onChange={setIPv6AssignMode} />
|
||||||
|
{ipv6AssignMode === 'random' && (
|
||||||
|
<Field label="随机数量">
|
||||||
|
<input type="number" min={1} max={64} value={ipv6AssignCount} onChange={(e) => setIPv6AssignCount(parseInt(e.target.value || '1', 10))} className={inputClass} />
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
{ipv6AssignMode === 'custom' && (
|
||||||
|
<Field label="IPv6 地址">
|
||||||
|
<textarea
|
||||||
|
value={ipv6DraftText}
|
||||||
|
onChange={(e) => {
|
||||||
|
setIPv6DraftText(e.target.value)
|
||||||
|
setIPv6AssignCount(Math.max(1, splitAddressLines(e.target.value).length || 1))
|
||||||
|
}}
|
||||||
|
className={`${inputClass} min-h-32 font-mono text-xs`}
|
||||||
|
placeholder="2001:db8:100::100 2001:db8:100::101"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-5 flex justify-end gap-2 border-t border-gray-200 pt-4">
|
||||||
|
<button onClick={() => setShowIPAssign(false)} disabled={savingIPAssign} className="rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 disabled:opacity-50">
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button onClick={submitIPAssign} disabled={savingIPAssign} className="inline-flex items-center gap-1.5 rounded-md bg-black px-3 py-2 text-sm text-white hover:bg-gray-800 disabled:opacity-50">
|
||||||
|
<Save className="h-4 w-4" />
|
||||||
|
{savingIPAssign ? '保存中...' : '保存'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
|
||||||
{showNat && !hasIndependentIPv4 && (
|
{showNat && !hasIndependentIPv4 && (
|
||||||
<Modal title="IPv4 NAT 端口管理" onClose={() => { setShowNat(false); setDraft(emptyDraft); setShowMappingEditor(false) }} wide extra={
|
<Modal title="IPv4 NAT 端口管理" onClose={() => { setShowNat(false); setDraft(emptyDraft); setShowMappingEditor(false) }} wide extra={
|
||||||
!isSubUser && canAddMapping && (
|
!isSubUser && canAddMapping && (
|
||||||
@@ -2442,6 +2587,28 @@ function Field({ label, children, hint }: { label: string; children: ReactNode;
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Segmented({ value, onChange }: { value: IPAssignMode; onChange: (value: IPAssignMode) => void }) {
|
||||||
|
const items: Array<{ value: IPAssignMode; label: string }> = [
|
||||||
|
{ value: 'clear', label: '不分配' },
|
||||||
|
{ value: 'random', label: '随机分配' },
|
||||||
|
{ value: 'custom', label: '自定义' },
|
||||||
|
]
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-3 gap-1 rounded-md bg-gray-100 p-1">
|
||||||
|
{items.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange(item.value)}
|
||||||
|
className={`rounded px-2 py-1.5 text-xs font-medium ${value === item.value ? 'bg-white text-black shadow-sm' : 'text-gray-600 hover:text-black'}`}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function Modal({ title, children, onClose, wide = false, extra, flush = false }: { title: string; children: ReactNode; onClose: () => void; wide?: boolean; extra?: ReactNode; flush?: boolean }) {
|
function Modal({ title, children, onClose, wide = false, extra, flush = false }: { title: string; children: ReactNode; onClose: () => void; wide?: boolean; extra?: ReactNode; flush?: boolean }) {
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||||
@@ -2493,6 +2660,31 @@ function normalizeContainerMetricSample(point: ContainerMetricSample): MetricPoi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function splitAddressLines(value: string) {
|
||||||
|
return value
|
||||||
|
.split(/[\n,,\s]+/)
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeIPv4Choices(candidates: PublicIPv4Info[], assigned: { address: string; interface?: string; prefix_len?: number; gateway?: string }[]) {
|
||||||
|
const byAddress = new Map<string, PublicIPv4Info>()
|
||||||
|
for (const item of candidates) {
|
||||||
|
if (item.address) byAddress.set(item.address, item)
|
||||||
|
}
|
||||||
|
for (const item of assigned) {
|
||||||
|
if (!item.address || byAddress.has(item.address)) continue
|
||||||
|
byAddress.set(item.address, {
|
||||||
|
address: item.address,
|
||||||
|
interface: item.interface || '',
|
||||||
|
prefix: item.prefix_len ? `${item.address}/${item.prefix_len}` : item.address,
|
||||||
|
prefix_len: item.prefix_len,
|
||||||
|
gateway: item.gateway,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return Array.from(byAddress.values()).sort((a, b) => a.address.localeCompare(b.address, undefined, { numeric: true }))
|
||||||
|
}
|
||||||
|
|
||||||
function historyKey(containerName: string) {
|
function historyKey(containerName: string) {
|
||||||
return `clicd_container_metric_history:${containerName}`
|
return `clicd_container_metric_history:${containerName}`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -578,6 +578,18 @@ export const getIPv6Status = () =>
|
|||||||
export const assignIPv6 = (id: ContainerIdentifier) =>
|
export const assignIPv6 = (id: ContainerIdentifier) =>
|
||||||
api.post<APIResponse<Container>>(`/containers/${id}/ipv6`)
|
api.post<APIResponse<Container>>(`/containers/${id}/ipv6`)
|
||||||
|
|
||||||
|
export interface IPAssignmentUpdateRequest {
|
||||||
|
mode: 'clear' | 'random' | 'custom'
|
||||||
|
count?: number
|
||||||
|
addresses?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const updatePublicIPv4Assignments = (id: ContainerIdentifier, data: IPAssignmentUpdateRequest) =>
|
||||||
|
api.put<APIResponse<Container>>(`/containers/${id}/public-ipv4`, data)
|
||||||
|
|
||||||
|
export const updateIPv6Assignments = (id: ContainerIdentifier, data: IPAssignmentUpdateRequest) =>
|
||||||
|
api.put<APIResponse<Container>>(`/containers/${id}/ipv6-addresses`, data)
|
||||||
|
|
||||||
export interface RouteCapacity {
|
export interface RouteCapacity {
|
||||||
used: number
|
used: number
|
||||||
remaining: string
|
remaining: string
|
||||||
|
|||||||
Reference in New Issue
Block a user