支持公网IPV4分配,单独IPV6分配,以及混合网络分配。

This commit is contained in:
MengMengCode
2026-06-09 22:22:40 +08:00
parent f4edf94800
commit 917afc3157
23 changed files with 3988 additions and 770 deletions
+28 -16
View File
@@ -228,13 +228,38 @@ func createContainer(w http.ResponseWriter, r *http.Request) {
if cfg.DiskGB < 1 {
cfg.DiskGB = 5
}
if cfg.PortMappingCount < 2 {
if cfg.PortMappingCount < 0 {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Port mapping count cannot be negative"})
return
}
if cfg.WantsNAT() && cfg.PortMappingCount < 2 {
cfg.PortMappingCount = 2
} else if !cfg.WantsNAT() {
cfg.PortMappingCount = 0
cfg.ExtraPorts = nil
}
if cfg.PortMappingCount > 64 {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Port mapping count cannot exceed 64"})
return
}
if cfg.IPv4Count < 0 || cfg.IPv6Count < 0 {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "IP address count cannot be negative"})
return
}
if cfg.IPv4Count > 64 || cfg.IPv6Count > 64 {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "IP address count cannot exceed 64"})
return
}
if !cfg.AssignIPv4 && len(cfg.PublicIPv4s) == 0 {
cfg.IPv4Count = 0
}
if !cfg.AssignIPv6 && len(cfg.IPv6Addresses) == 0 {
cfg.IPv6Count = 0
}
if !hasRequestedNetwork(cfg) {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: noNetworkSelectedMessage})
return
}
if cfg.SnapshotLimit <= 0 {
cfg.SnapshotLimit = config.DefaultSnapshotLimit
}
@@ -405,24 +430,11 @@ func getRandomPort(w http.ResponseWriter, r *http.Request, id int) {
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"})
return
}
// Find a random unused port between 10000-65535
used := map[int]bool{}
for _, pm := range c.PortMappings {
used[pm.HostPort] = true
}
// Also check all containers
for _, oc := range config.AppConfig.Containers {
if oc.ID == id {
continue
}
for _, pm := range oc.PortMappings {
used[pm.HostPort] = true
}
}
hostIP := strings.TrimSpace(r.URL.Query().Get("host_ip"))
// Try random ports
for tries := 0; tries < 100; tries++ {
port := 10000 + (int(time.Now().UnixNano()) % 55535)
if !used[port] {
if lxc.HostPortAvailable(c, hostIP, port, "tcp") {
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]int{"port": port}})
return
}
+50 -7
View File
@@ -85,6 +85,7 @@ type HostDiskProbe struct {
Serial string `json:"serial"`
SizeBytes uint64 `json:"size_bytes"`
Type string `json:"type"`
Virtual bool `json:"virtual"`
Rotational bool `json:"rotational"`
Mountpoints []string `json:"mountpoints"`
Health string `json:"health"`
@@ -201,6 +202,7 @@ type NetworkInfo struct {
TXBps float64 `json:"tx_bps"`
PublicIPv4 string `json:"public_ipv4"`
PublicIPv4Interface string `json:"public_ipv4_interface"`
PublicIPv4Addresses []lxc.PublicIPInfo `json:"public_ipv4_addresses"`
PublicIPv6 string `json:"public_ipv6"`
PublicIPv6Interface string `json:"public_ipv6_interface"`
IPv6Prefixes []lxc.IPv6PrefixInfo `json:"ipv6_prefixes"`
@@ -398,7 +400,8 @@ func getHostRates() (NetworkInfo, DiskIOInfo) {
publicIPv4 := lxc.DetectPublicIPv4()
network.PublicIPv4 = publicIPv4.Address
network.PublicIPv4Interface = publicIPv4.Interface
network.IPv6Prefixes = lxc.DetectPublicIPv6Prefixes()
network.PublicIPv4Addresses = lxc.DetectFreePublicIPv4Candidates(0)
network.IPv6Prefixes = lxc.DetectHostPublicIPv6Prefixes()
if len(network.IPv6Prefixes) > 0 {
network.PublicIPv6 = network.IPv6Prefixes[0].Address
network.PublicIPv6Interface = network.IPv6Prefixes[0].Interface
@@ -540,7 +543,7 @@ func getHostProbeReport() HostProbeReport {
Disks: detectHostDisks(),
NetworkInterfaces: detectHostNICs(),
PublicIPv4: detectAllPublicIPv4(),
IPv6Prefixes: lxc.DetectPublicIPv6Prefixes(),
IPv6Prefixes: lxc.DetectHostPublicIPv6Prefixes(),
Gateways: detectGateways(),
GPUs: detectGPUs(),
System: detectSystemProbe(),
@@ -676,17 +679,23 @@ func detectHostDisks() []HostDiskProbe {
}
base := filepath.Join("/sys/block", name)
path := "/dev/" + name
model := strings.TrimSpace(readFirstExistingFile(filepath.Join(base, "device/model"), filepath.Join(base, "device/name")))
vendor := strings.TrimSpace(readFirstExistingFile(filepath.Join(base, "device/vendor")))
virtual := isVirtualBlockDevice(name, model, vendor)
disk := HostDiskProbe{
Name: name,
Path: path,
Model: strings.TrimSpace(readFirstExistingFile(filepath.Join(base, "device/model"), filepath.Join(base, "device/name"))),
Model: model,
Serial: strings.TrimSpace(readFirstExistingFile(filepath.Join(base, "device/serial"), filepath.Join(base, "serial"))),
SizeBytes: readUintFile(filepath.Join(base, "size")) * 512,
Type: detectDiskType(base, name),
Type: detectDiskType(base, name, virtual),
Virtual: virtual,
Rotational: strings.TrimSpace(readFirstExistingFile(filepath.Join(base, "queue/rotational"))) == "1",
Mountpoints: mounts[name],
}
disk.SMART = detectDiskSMART(path)
if !virtual {
disk.SMART = detectDiskSMART(path)
}
disk.Health = disk.SMARTHealth()
disk.HealthDetail = disk.SMARTDetail()
disks = append(disks, disk)
@@ -695,7 +704,10 @@ func detectHostDisks() []HostDiskProbe {
return disks
}
func detectDiskType(base, name string) string {
func detectDiskType(base, name string, virtual bool) string {
if virtual {
return "Virtual"
}
if strings.HasPrefix(name, "nvme") {
return "NVMe"
}
@@ -705,7 +717,26 @@ func detectDiskType(base, name string) string {
return "SSD"
}
func isVirtualBlockDevice(name, model, vendor string) bool {
lower := strings.ToLower(strings.TrimSpace(name + " " + model + " " + vendor))
if strings.HasPrefix(name, "vd") || strings.HasPrefix(name, "xvd") {
return true
}
for _, token := range []string{
"qemu", "virtio", "virtual", "vmware", "vbox", "xen",
"amazon elastic block store", "google persistentdisk", "microsoft",
} {
if strings.Contains(lower, token) {
return true
}
}
return false
}
func (disk HostDiskProbe) SMARTHealth() string {
if disk.Virtual {
return "virtual"
}
if disk.SMART.Available && disk.Health != "" {
return disk.Health
}
@@ -713,6 +744,9 @@ func (disk HostDiskProbe) SMARTHealth() string {
}
func (disk HostDiskProbe) SMARTDetail() string {
if disk.Virtual {
return "虚拟磁盘,真实 SMART/寿命/通电数据需在物理宿主机查看"
}
return disk.SMART.Detail()
}
@@ -1437,7 +1471,7 @@ func commandCheck(key, label string, required bool, cmd string, fallback string)
ok := commandExists(cmd)
detail := "missing"
if ok {
detail = strings.TrimSpace(runCommandOutput(2*time.Second, "sh", "-c", cmd+" --version 2>&1 | head -n 1"))
detail = commandVersionDetail(cmd)
if detail == "" {
detail = "installed"
}
@@ -1447,6 +1481,15 @@ func commandCheck(key, label string, required bool, cmd string, fallback string)
return HostEnvCheck{Key: key, Label: label, OK: ok, Required: required, Detail: detail}
}
func commandVersionDetail(cmd string) string {
switch cmd {
case "ip":
return strings.TrimSpace(runCommandOutput(2*time.Second, "sh", "-c", "ip -V 2>&1 | head -n 1"))
default:
return strings.TrimSpace(runCommandOutput(2*time.Second, "sh", "-c", cmd+" --version 2>&1 | head -n 1"))
}
}
func certbotCheck() HostEnvCheck {
check := HostEnvCheck{Key: "certbot", Label: "Certbot 证书工具 >= 5.4", Required: false, Detail: "missing"}
if !commandExists("certbot") {
+236 -19
View File
@@ -1,7 +1,9 @@
package api
import (
"encoding/json"
"net/http"
"net/netip"
"sort"
"strconv"
@@ -21,12 +23,24 @@ type nat4Route struct {
LXCName string `json:"lxc_name"`
Status string `json:"status"`
IP string `json:"ip"`
HostIP string `json:"host_ip"`
HostPort int `json:"host_port"`
ContainerPort int `json:"container_port"`
Protocol string `json:"protocol"`
Description string `json:"description"`
}
type ipv4Route struct {
ContainerID int `json:"container_id"`
ContainerName string `json:"container_name"`
LXCName string `json:"lxc_name"`
Status string `json:"status"`
Address string `json:"address"`
Interface string `json:"interface"`
PrefixLen int `json:"prefix_len,omitempty"`
Gateway string `json:"gateway,omitempty"`
}
type ipv6Route struct {
ContainerID int `json:"container_id"`
ContainerName string `json:"container_name"`
@@ -38,30 +52,78 @@ type ipv6Route struct {
}
type routingResponse struct {
NAT4 routeCapacity `json:"nat4"`
IPv6 routeCapacity `json:"ipv6"`
NAT4Mappings []nat4Route `json:"nat4_mappings"`
IPv6Assignments []ipv6Route `json:"ipv6_assignments"`
IPv6Prefixes []lxc.IPv6PrefixInfo `json:"ipv6_prefixes"`
NAT4 routeCapacity `json:"nat4"`
IPv4 routeCapacity `json:"ipv4"`
IPv6 routeCapacity `json:"ipv6"`
HostPublicIPv4 lxc.PublicIPInfo `json:"host_public_ipv4"`
PublicIPv4Addresses []lxc.PublicIPInfo `json:"public_ipv4_addresses"`
IPv4Assignments []ipv4Route `json:"ipv4_assignments"`
NAT4Mappings []nat4Route `json:"nat4_mappings"`
IPv6Assignments []ipv6Route `json:"ipv6_assignments"`
IPv6Prefixes []lxc.IPv6PrefixInfo `json:"ipv6_prefixes"`
}
type routingPoolsRequest struct {
Addresses *[]string `json:"addresses"`
Items *[]config.PublicIPv4Assignment `json:"items"`
IPv6Prefixes *[]config.PublicIPv6Prefix `json:"ipv6_prefixes"`
}
type publicIPv4ScanRequest struct {
CIDR string `json:"cidr"`
Interface string `json:"interface"`
Gateway string `json:"gateway"`
Verify bool `json:"verify"`
Limit int `json:"limit"`
}
func HandleRouting(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
switch r.Method {
case http.MethodGet:
handleRoutingGet(w, r)
case http.MethodPut:
handleRoutingPoolsUpdate(w, r)
default:
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
}
}
func HandleRoutingIPv4Scan(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
if !requireScope(w, r, "routing:write") {
return
}
var req publicIPv4ScanRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
return
}
results, err := lxc.ScanPublicIPv4Segment(req.CIDR, req.Interface, req.Gateway, req.Verify, req.Limit)
if err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
return
}
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: results})
}
func handleRoutingGet(w http.ResponseWriter, r *http.Request) {
if !requireScope(w, r, "routing:read") {
return
}
nat4Mappings := make([]nat4Route, 0)
usedPorts := map[int]bool{}
ipv4Assignments := make([]ipv4Route, 0)
ipv6Assignments := make([]ipv6Route, 0)
const nat4StartPort = 20000
const nat4EndPort = 65535
for _, c := range config.AppConfig.Containers {
for i := range config.AppConfig.Containers {
c := &config.AppConfig.Containers[i]
for _, pm := range c.PortMappings {
if pm.HostPort >= nat4StartPort && pm.HostPort <= nat4EndPort {
usedPorts[pm.HostPort] = true
@@ -72,30 +134,56 @@ func HandleRouting(w http.ResponseWriter, r *http.Request) {
LXCName: c.LxcName(),
Status: c.Status,
IP: c.IP,
HostIP: pm.HostIP,
HostPort: pm.HostPort,
ContainerPort: pm.ContainerPort,
Protocol: pm.Protocol,
Description: pm.Description,
})
}
if c.IPv6 != "" {
for _, ip := range c.PublicIPv4s {
if ip.Address == "" {
continue
}
ipv4Assignments = append(ipv4Assignments, ipv4Route{
ContainerID: c.ID,
ContainerName: c.Name,
LXCName: c.LxcName(),
Status: c.Status,
Address: ip.Address,
Interface: ip.Interface,
PrefixLen: ip.PrefixLen,
Gateway: ip.Gateway,
})
}
c.NormalizeNetworkAssignments()
for _, ip := range c.IPv6Addresses {
if ip.Address == "" {
continue
}
ipv6Assignments = append(ipv6Assignments, ipv6Route{
ContainerID: c.ID,
ContainerName: c.Name,
LXCName: c.LxcName(),
Status: c.Status,
Address: c.IPv6,
PrefixLen: c.IPv6PrefixLen,
Interface: c.IPv6Interface,
Address: ip.Address,
PrefixLen: ip.PrefixLen,
Interface: ip.Interface,
})
}
}
sort.SliceStable(nat4Mappings, func(i, j int) bool {
if nat4Mappings[i].HostPort == nat4Mappings[j].HostPort {
if nat4Mappings[i].HostIP != nat4Mappings[j].HostIP {
return nat4Mappings[i].HostIP < nat4Mappings[j].HostIP
}
return nat4Mappings[i].ContainerName < nat4Mappings[j].ContainerName
}
return nat4Mappings[i].HostPort < nat4Mappings[j].HostPort
})
sort.SliceStable(ipv4Assignments, func(i, j int) bool {
return ipv4Assignments[i].Address < ipv4Assignments[j].Address
})
sort.SliceStable(ipv6Assignments, func(i, j int) bool {
return ipv6Assignments[i].Address < ipv6Assignments[j].Address
})
@@ -108,12 +196,16 @@ func HandleRouting(w http.ResponseWriter, r *http.Request) {
}
prefixes := lxc.DetectPublicIPv6Prefixes()
ipv6Total := "0"
ipv6Remaining := "0"
if len(prefixes) > 0 {
ipv6Total = lxc.IPv6PrefixCapacity(prefixes[0].PrefixLen)
ipv6Remaining = subtractCapacity(ipv6Total, len(ipv6Assignments))
hostPublicIPv4 := lxc.DetectPublicIPv4()
publicIPv4s := lxc.DetectPublicIPv4Candidates()
ipv4Total := len(publicIPv4s)
ipv4Used := len(ipv4Assignments)
ipv4Remaining := ipv4Total - ipv4Used
if ipv4Remaining < 0 {
ipv4Remaining = 0
}
ipv6Total := totalIPv6Capacity(prefixes)
ipv6Remaining := subtractCapacity(ipv6Total, len(ipv6Assignments))
jsonResponse(w, http.StatusOK, APIResponse{
Success: true,
@@ -123,18 +215,143 @@ func HandleRouting(w http.ResponseWriter, r *http.Request) {
Remaining: strconv.Itoa(nat4Remaining),
Total: strconv.Itoa(totalNAT4Ports),
},
IPv4: routeCapacity{
Used: ipv4Used,
Remaining: strconv.Itoa(ipv4Remaining),
Total: strconv.Itoa(ipv4Total),
},
IPv6: routeCapacity{
Used: len(ipv6Assignments),
Remaining: ipv6Remaining,
Total: ipv6Total,
},
NAT4Mappings: nat4Mappings,
IPv6Assignments: ipv6Assignments,
IPv6Prefixes: prefixes,
HostPublicIPv4: hostPublicIPv4,
PublicIPv4Addresses: publicIPv4s,
IPv4Assignments: ipv4Assignments,
NAT4Mappings: nat4Mappings,
IPv6Assignments: ipv6Assignments,
IPv6Prefixes: prefixes,
},
})
}
func handleRoutingPoolsUpdate(w http.ResponseWriter, r *http.Request) {
if !requireScope(w, r, "routing:write") {
return
}
var req routingPoolsRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
return
}
if req.Items != nil || req.Addresses != nil {
items := []config.PublicIPv4Assignment{}
if req.Items != nil {
items = *req.Items
} else if req.Addresses != nil {
items = make([]config.PublicIPv4Assignment, 0, len(*req.Addresses))
for _, address := range *req.Addresses {
items = append(items, config.PublicIPv4Assignment{Address: address})
}
}
normalized, err := lxc.NormalizePublicIPv4Pool(items)
if err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
return
}
allowed := map[string]bool{}
for _, item := range normalized {
allowed[item.Address] = true
}
for _, c := range config.AppConfig.Containers {
for _, item := range c.PublicIPv4s {
if item.Address != "" && !allowed[item.Address] {
jsonResponse(w, http.StatusBadRequest, APIResponse{
Success: false,
Message: "IPv4 " + item.Address + " is assigned to container " + c.Name + " and cannot be removed from the pool",
})
return
}
}
}
config.AppConfig.PublicIPv4Pool = normalized
}
if req.IPv6Prefixes != nil {
normalized, err := lxc.NormalizePublicIPv6Prefixes(*req.IPv6Prefixes)
if err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
return
}
parsedPrefixes := make([]netip.Prefix, 0, len(normalized))
for _, item := range normalized {
prefix, err := netip.ParsePrefix(item.Prefix)
if err == nil {
parsedPrefixes = append(parsedPrefixes, prefix)
}
}
for _, c := range config.AppConfig.Containers {
c.NormalizeNetworkAssignments()
for _, item := range c.IPv6Addresses {
if item.Address == "" {
continue
}
addr, err := netip.ParseAddr(item.Address)
if err != nil {
continue
}
contained := false
for _, prefix := range parsedPrefixes {
if prefix.Contains(addr) {
contained = true
break
}
}
if !contained {
jsonResponse(w, http.StatusBadRequest, APIResponse{
Success: false,
Message: "IPv6 " + item.Address + " is assigned to container " + c.Name + " and cannot be removed from the pool",
})
return
}
}
}
config.AppConfig.PublicIPv6Prefixes = normalized
}
if err := config.SaveConfig(); err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to save configuration"})
return
}
handleRoutingGet(w, r)
}
func totalIPv6Capacity(prefixes []lxc.IPv6PrefixInfo) string {
if len(prefixes) == 0 {
return "0"
}
var total uint64
for _, prefix := range prefixes {
capacity := lxc.IPv6PrefixCapacity(prefix.PrefixLen)
if capacity == "large" {
return "large"
}
parsed, err := strconv.ParseUint(capacity, 10, 64)
if err != nil {
continue
}
if ^uint64(0)-total < parsed {
return "large"
}
total += parsed
}
if total == 0 {
return "0"
}
return strconv.FormatUint(total, 10)
}
func subtractCapacity(total string, used int) string {
if total == "" || total == "0" {
return "0"
+6
View File
@@ -13,10 +13,16 @@ import (
var kvmManager = kvm.NewManager()
const noNetworkSelectedMessage = "请勾选任意一个可用网络"
func runtimeFromRequest(value string) string {
return config.NormalizeVirtualization(value)
}
func hasRequestedNetwork(cfg lxc.ContainerConfig) bool {
return cfg.WantsNAT() || cfg.AssignIPv4 || len(cfg.PublicIPv4s) > 0 || cfg.AssignIPv6 || len(cfg.IPv6Addresses) > 0
}
func runtimeFromTemplateID(templateID string) string {
if kvm.FindImage(templateID) != nil {
return config.VirtualizationKVM
+30 -1
View File
@@ -575,8 +575,37 @@ func HandleBatchCreate(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: name + ": template is not enabled or downloaded"})
return
}
if req.Containers[i].PortMappingCount < 2 {
if req.Containers[i].PortMappingCount < 0 {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: name + ": port mapping count cannot be negative"})
return
}
if req.Containers[i].WantsNAT() && req.Containers[i].PortMappingCount < 2 {
req.Containers[i].PortMappingCount = 2
} else if !req.Containers[i].WantsNAT() {
req.Containers[i].PortMappingCount = 0
req.Containers[i].ExtraPorts = nil
}
if req.Containers[i].PortMappingCount > 64 {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: name + ": port mapping count cannot exceed 64"})
return
}
if req.Containers[i].IPv4Count < 0 || req.Containers[i].IPv6Count < 0 {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: name + ": IP address count cannot be negative"})
return
}
if req.Containers[i].IPv4Count > 64 || req.Containers[i].IPv6Count > 64 {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: name + ": IP address count cannot exceed 64"})
return
}
if !req.Containers[i].AssignIPv4 && len(req.Containers[i].PublicIPv4s) == 0 {
req.Containers[i].IPv4Count = 0
}
if !req.Containers[i].AssignIPv6 && len(req.Containers[i].IPv6Addresses) == 0 {
req.Containers[i].IPv6Count = 0
}
if !hasRequestedNetwork(req.Containers[i]) {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: name + ": " + noNetworkSelectedMessage})
return
}
if req.Containers[i].SnapshotLimit <= 0 {
req.Containers[i].SnapshotLimit = config.DefaultSnapshotLimit