mirror of
https://github.com/MengMengCode/CLICD.git
synced 2026-08-06 05:52:19 +08:00
支持公网IPV4分配,单独IPV6分配,以及混合网络分配。
This commit is contained in:
@@ -228,13 +228,38 @@ func createContainer(w http.ResponseWriter, r *http.Request) {
|
|||||||
if cfg.DiskGB < 1 {
|
if cfg.DiskGB < 1 {
|
||||||
cfg.DiskGB = 5
|
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
|
cfg.PortMappingCount = 2
|
||||||
|
} else if !cfg.WantsNAT() {
|
||||||
|
cfg.PortMappingCount = 0
|
||||||
|
cfg.ExtraPorts = nil
|
||||||
}
|
}
|
||||||
if cfg.PortMappingCount > 64 {
|
if cfg.PortMappingCount > 64 {
|
||||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Port mapping count cannot exceed 64"})
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Port mapping count cannot exceed 64"})
|
||||||
return
|
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 {
|
if cfg.SnapshotLimit <= 0 {
|
||||||
cfg.SnapshotLimit = config.DefaultSnapshotLimit
|
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"})
|
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Find a random unused port between 10000-65535
|
hostIP := strings.TrimSpace(r.URL.Query().Get("host_ip"))
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Try random ports
|
// Try random ports
|
||||||
for tries := 0; tries < 100; tries++ {
|
for tries := 0; tries < 100; tries++ {
|
||||||
port := 10000 + (int(time.Now().UnixNano()) % 55535)
|
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}})
|
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]int{"port": port}})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ type HostDiskProbe struct {
|
|||||||
Serial string `json:"serial"`
|
Serial string `json:"serial"`
|
||||||
SizeBytes uint64 `json:"size_bytes"`
|
SizeBytes uint64 `json:"size_bytes"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
|
Virtual bool `json:"virtual"`
|
||||||
Rotational bool `json:"rotational"`
|
Rotational bool `json:"rotational"`
|
||||||
Mountpoints []string `json:"mountpoints"`
|
Mountpoints []string `json:"mountpoints"`
|
||||||
Health string `json:"health"`
|
Health string `json:"health"`
|
||||||
@@ -201,6 +202,7 @@ type NetworkInfo struct {
|
|||||||
TXBps float64 `json:"tx_bps"`
|
TXBps float64 `json:"tx_bps"`
|
||||||
PublicIPv4 string `json:"public_ipv4"`
|
PublicIPv4 string `json:"public_ipv4"`
|
||||||
PublicIPv4Interface string `json:"public_ipv4_interface"`
|
PublicIPv4Interface string `json:"public_ipv4_interface"`
|
||||||
|
PublicIPv4Addresses []lxc.PublicIPInfo `json:"public_ipv4_addresses"`
|
||||||
PublicIPv6 string `json:"public_ipv6"`
|
PublicIPv6 string `json:"public_ipv6"`
|
||||||
PublicIPv6Interface string `json:"public_ipv6_interface"`
|
PublicIPv6Interface string `json:"public_ipv6_interface"`
|
||||||
IPv6Prefixes []lxc.IPv6PrefixInfo `json:"ipv6_prefixes"`
|
IPv6Prefixes []lxc.IPv6PrefixInfo `json:"ipv6_prefixes"`
|
||||||
@@ -398,7 +400,8 @@ func getHostRates() (NetworkInfo, DiskIOInfo) {
|
|||||||
publicIPv4 := lxc.DetectPublicIPv4()
|
publicIPv4 := lxc.DetectPublicIPv4()
|
||||||
network.PublicIPv4 = publicIPv4.Address
|
network.PublicIPv4 = publicIPv4.Address
|
||||||
network.PublicIPv4Interface = publicIPv4.Interface
|
network.PublicIPv4Interface = publicIPv4.Interface
|
||||||
network.IPv6Prefixes = lxc.DetectPublicIPv6Prefixes()
|
network.PublicIPv4Addresses = lxc.DetectFreePublicIPv4Candidates(0)
|
||||||
|
network.IPv6Prefixes = lxc.DetectHostPublicIPv6Prefixes()
|
||||||
if len(network.IPv6Prefixes) > 0 {
|
if len(network.IPv6Prefixes) > 0 {
|
||||||
network.PublicIPv6 = network.IPv6Prefixes[0].Address
|
network.PublicIPv6 = network.IPv6Prefixes[0].Address
|
||||||
network.PublicIPv6Interface = network.IPv6Prefixes[0].Interface
|
network.PublicIPv6Interface = network.IPv6Prefixes[0].Interface
|
||||||
@@ -540,7 +543,7 @@ func getHostProbeReport() HostProbeReport {
|
|||||||
Disks: detectHostDisks(),
|
Disks: detectHostDisks(),
|
||||||
NetworkInterfaces: detectHostNICs(),
|
NetworkInterfaces: detectHostNICs(),
|
||||||
PublicIPv4: detectAllPublicIPv4(),
|
PublicIPv4: detectAllPublicIPv4(),
|
||||||
IPv6Prefixes: lxc.DetectPublicIPv6Prefixes(),
|
IPv6Prefixes: lxc.DetectHostPublicIPv6Prefixes(),
|
||||||
Gateways: detectGateways(),
|
Gateways: detectGateways(),
|
||||||
GPUs: detectGPUs(),
|
GPUs: detectGPUs(),
|
||||||
System: detectSystemProbe(),
|
System: detectSystemProbe(),
|
||||||
@@ -676,17 +679,23 @@ func detectHostDisks() []HostDiskProbe {
|
|||||||
}
|
}
|
||||||
base := filepath.Join("/sys/block", name)
|
base := filepath.Join("/sys/block", name)
|
||||||
path := "/dev/" + 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{
|
disk := HostDiskProbe{
|
||||||
Name: name,
|
Name: name,
|
||||||
Path: path,
|
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"))),
|
Serial: strings.TrimSpace(readFirstExistingFile(filepath.Join(base, "device/serial"), filepath.Join(base, "serial"))),
|
||||||
SizeBytes: readUintFile(filepath.Join(base, "size")) * 512,
|
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",
|
Rotational: strings.TrimSpace(readFirstExistingFile(filepath.Join(base, "queue/rotational"))) == "1",
|
||||||
Mountpoints: mounts[name],
|
Mountpoints: mounts[name],
|
||||||
}
|
}
|
||||||
|
if !virtual {
|
||||||
disk.SMART = detectDiskSMART(path)
|
disk.SMART = detectDiskSMART(path)
|
||||||
|
}
|
||||||
disk.Health = disk.SMARTHealth()
|
disk.Health = disk.SMARTHealth()
|
||||||
disk.HealthDetail = disk.SMARTDetail()
|
disk.HealthDetail = disk.SMARTDetail()
|
||||||
disks = append(disks, disk)
|
disks = append(disks, disk)
|
||||||
@@ -695,7 +704,10 @@ func detectHostDisks() []HostDiskProbe {
|
|||||||
return disks
|
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") {
|
if strings.HasPrefix(name, "nvme") {
|
||||||
return "NVMe"
|
return "NVMe"
|
||||||
}
|
}
|
||||||
@@ -705,7 +717,26 @@ func detectDiskType(base, name string) string {
|
|||||||
return "SSD"
|
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 {
|
func (disk HostDiskProbe) SMARTHealth() string {
|
||||||
|
if disk.Virtual {
|
||||||
|
return "virtual"
|
||||||
|
}
|
||||||
if disk.SMART.Available && disk.Health != "" {
|
if disk.SMART.Available && disk.Health != "" {
|
||||||
return disk.Health
|
return disk.Health
|
||||||
}
|
}
|
||||||
@@ -713,6 +744,9 @@ func (disk HostDiskProbe) SMARTHealth() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (disk HostDiskProbe) SMARTDetail() string {
|
func (disk HostDiskProbe) SMARTDetail() string {
|
||||||
|
if disk.Virtual {
|
||||||
|
return "虚拟磁盘,真实 SMART/寿命/通电数据需在物理宿主机查看"
|
||||||
|
}
|
||||||
return disk.SMART.Detail()
|
return disk.SMART.Detail()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1437,7 +1471,7 @@ func commandCheck(key, label string, required bool, cmd string, fallback string)
|
|||||||
ok := commandExists(cmd)
|
ok := commandExists(cmd)
|
||||||
detail := "missing"
|
detail := "missing"
|
||||||
if ok {
|
if ok {
|
||||||
detail = strings.TrimSpace(runCommandOutput(2*time.Second, "sh", "-c", cmd+" --version 2>&1 | head -n 1"))
|
detail = commandVersionDetail(cmd)
|
||||||
if detail == "" {
|
if detail == "" {
|
||||||
detail = "installed"
|
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}
|
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 {
|
func certbotCheck() HostEnvCheck {
|
||||||
check := HostEnvCheck{Key: "certbot", Label: "Certbot 证书工具 >= 5.4", Required: false, Detail: "missing"}
|
check := HostEnvCheck{Key: "certbot", Label: "Certbot 证书工具 >= 5.4", Required: false, Detail: "missing"}
|
||||||
if !commandExists("certbot") {
|
if !commandExists("certbot") {
|
||||||
|
|||||||
+228
-11
@@ -1,7 +1,9 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
@@ -21,12 +23,24 @@ type nat4Route struct {
|
|||||||
LXCName string `json:"lxc_name"`
|
LXCName string `json:"lxc_name"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
IP string `json:"ip"`
|
IP string `json:"ip"`
|
||||||
|
HostIP string `json:"host_ip"`
|
||||||
HostPort int `json:"host_port"`
|
HostPort int `json:"host_port"`
|
||||||
ContainerPort int `json:"container_port"`
|
ContainerPort int `json:"container_port"`
|
||||||
Protocol string `json:"protocol"`
|
Protocol string `json:"protocol"`
|
||||||
Description string `json:"description"`
|
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 {
|
type ipv6Route struct {
|
||||||
ContainerID int `json:"container_id"`
|
ContainerID int `json:"container_id"`
|
||||||
ContainerName string `json:"container_name"`
|
ContainerName string `json:"container_name"`
|
||||||
@@ -39,29 +53,77 @@ type ipv6Route struct {
|
|||||||
|
|
||||||
type routingResponse struct {
|
type routingResponse struct {
|
||||||
NAT4 routeCapacity `json:"nat4"`
|
NAT4 routeCapacity `json:"nat4"`
|
||||||
|
IPv4 routeCapacity `json:"ipv4"`
|
||||||
IPv6 routeCapacity `json:"ipv6"`
|
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"`
|
NAT4Mappings []nat4Route `json:"nat4_mappings"`
|
||||||
IPv6Assignments []ipv6Route `json:"ipv6_assignments"`
|
IPv6Assignments []ipv6Route `json:"ipv6_assignments"`
|
||||||
IPv6Prefixes []lxc.IPv6PrefixInfo `json:"ipv6_prefixes"`
|
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) {
|
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"})
|
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||||
return
|
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") {
|
if !requireScope(w, r, "routing:read") {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
nat4Mappings := make([]nat4Route, 0)
|
nat4Mappings := make([]nat4Route, 0)
|
||||||
usedPorts := map[int]bool{}
|
usedPorts := map[int]bool{}
|
||||||
|
ipv4Assignments := make([]ipv4Route, 0)
|
||||||
ipv6Assignments := make([]ipv6Route, 0)
|
ipv6Assignments := make([]ipv6Route, 0)
|
||||||
|
|
||||||
const nat4StartPort = 20000
|
const nat4StartPort = 20000
|
||||||
const nat4EndPort = 65535
|
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 {
|
for _, pm := range c.PortMappings {
|
||||||
if pm.HostPort >= nat4StartPort && pm.HostPort <= nat4EndPort {
|
if pm.HostPort >= nat4StartPort && pm.HostPort <= nat4EndPort {
|
||||||
usedPorts[pm.HostPort] = true
|
usedPorts[pm.HostPort] = true
|
||||||
@@ -72,30 +134,56 @@ func HandleRouting(w http.ResponseWriter, r *http.Request) {
|
|||||||
LXCName: c.LxcName(),
|
LXCName: c.LxcName(),
|
||||||
Status: c.Status,
|
Status: c.Status,
|
||||||
IP: c.IP,
|
IP: c.IP,
|
||||||
|
HostIP: pm.HostIP,
|
||||||
HostPort: pm.HostPort,
|
HostPort: pm.HostPort,
|
||||||
ContainerPort: pm.ContainerPort,
|
ContainerPort: pm.ContainerPort,
|
||||||
Protocol: pm.Protocol,
|
Protocol: pm.Protocol,
|
||||||
Description: pm.Description,
|
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{
|
ipv6Assignments = append(ipv6Assignments, ipv6Route{
|
||||||
ContainerID: c.ID,
|
ContainerID: c.ID,
|
||||||
ContainerName: c.Name,
|
ContainerName: c.Name,
|
||||||
LXCName: c.LxcName(),
|
LXCName: c.LxcName(),
|
||||||
Status: c.Status,
|
Status: c.Status,
|
||||||
Address: c.IPv6,
|
Address: ip.Address,
|
||||||
PrefixLen: c.IPv6PrefixLen,
|
PrefixLen: ip.PrefixLen,
|
||||||
Interface: c.IPv6Interface,
|
Interface: ip.Interface,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
sort.SliceStable(nat4Mappings, func(i, j int) bool {
|
sort.SliceStable(nat4Mappings, func(i, j int) bool {
|
||||||
if nat4Mappings[i].HostPort == nat4Mappings[j].HostPort {
|
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].ContainerName < nat4Mappings[j].ContainerName
|
||||||
}
|
}
|
||||||
return nat4Mappings[i].HostPort < nat4Mappings[j].HostPort
|
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 {
|
sort.SliceStable(ipv6Assignments, func(i, j int) bool {
|
||||||
return ipv6Assignments[i].Address < ipv6Assignments[j].Address
|
return ipv6Assignments[i].Address < ipv6Assignments[j].Address
|
||||||
})
|
})
|
||||||
@@ -108,12 +196,16 @@ func HandleRouting(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
prefixes := lxc.DetectPublicIPv6Prefixes()
|
prefixes := lxc.DetectPublicIPv6Prefixes()
|
||||||
ipv6Total := "0"
|
hostPublicIPv4 := lxc.DetectPublicIPv4()
|
||||||
ipv6Remaining := "0"
|
publicIPv4s := lxc.DetectPublicIPv4Candidates()
|
||||||
if len(prefixes) > 0 {
|
ipv4Total := len(publicIPv4s)
|
||||||
ipv6Total = lxc.IPv6PrefixCapacity(prefixes[0].PrefixLen)
|
ipv4Used := len(ipv4Assignments)
|
||||||
ipv6Remaining = subtractCapacity(ipv6Total, len(ipv6Assignments))
|
ipv4Remaining := ipv4Total - ipv4Used
|
||||||
|
if ipv4Remaining < 0 {
|
||||||
|
ipv4Remaining = 0
|
||||||
}
|
}
|
||||||
|
ipv6Total := totalIPv6Capacity(prefixes)
|
||||||
|
ipv6Remaining := subtractCapacity(ipv6Total, len(ipv6Assignments))
|
||||||
|
|
||||||
jsonResponse(w, http.StatusOK, APIResponse{
|
jsonResponse(w, http.StatusOK, APIResponse{
|
||||||
Success: true,
|
Success: true,
|
||||||
@@ -123,11 +215,19 @@ func HandleRouting(w http.ResponseWriter, r *http.Request) {
|
|||||||
Remaining: strconv.Itoa(nat4Remaining),
|
Remaining: strconv.Itoa(nat4Remaining),
|
||||||
Total: strconv.Itoa(totalNAT4Ports),
|
Total: strconv.Itoa(totalNAT4Ports),
|
||||||
},
|
},
|
||||||
|
IPv4: routeCapacity{
|
||||||
|
Used: ipv4Used,
|
||||||
|
Remaining: strconv.Itoa(ipv4Remaining),
|
||||||
|
Total: strconv.Itoa(ipv4Total),
|
||||||
|
},
|
||||||
IPv6: routeCapacity{
|
IPv6: routeCapacity{
|
||||||
Used: len(ipv6Assignments),
|
Used: len(ipv6Assignments),
|
||||||
Remaining: ipv6Remaining,
|
Remaining: ipv6Remaining,
|
||||||
Total: ipv6Total,
|
Total: ipv6Total,
|
||||||
},
|
},
|
||||||
|
HostPublicIPv4: hostPublicIPv4,
|
||||||
|
PublicIPv4Addresses: publicIPv4s,
|
||||||
|
IPv4Assignments: ipv4Assignments,
|
||||||
NAT4Mappings: nat4Mappings,
|
NAT4Mappings: nat4Mappings,
|
||||||
IPv6Assignments: ipv6Assignments,
|
IPv6Assignments: ipv6Assignments,
|
||||||
IPv6Prefixes: prefixes,
|
IPv6Prefixes: prefixes,
|
||||||
@@ -135,6 +235,123 @@ func HandleRouting(w http.ResponseWriter, r *http.Request) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
func subtractCapacity(total string, used int) string {
|
||||||
if total == "" || total == "0" {
|
if total == "" || total == "0" {
|
||||||
return "0"
|
return "0"
|
||||||
|
|||||||
@@ -13,10 +13,16 @@ import (
|
|||||||
|
|
||||||
var kvmManager = kvm.NewManager()
|
var kvmManager = kvm.NewManager()
|
||||||
|
|
||||||
|
const noNetworkSelectedMessage = "请勾选任意一个可用网络"
|
||||||
|
|
||||||
func runtimeFromRequest(value string) string {
|
func runtimeFromRequest(value string) string {
|
||||||
return config.NormalizeVirtualization(value)
|
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 {
|
func runtimeFromTemplateID(templateID string) string {
|
||||||
if kvm.FindImage(templateID) != nil {
|
if kvm.FindImage(templateID) != nil {
|
||||||
return config.VirtualizationKVM
|
return config.VirtualizationKVM
|
||||||
|
|||||||
@@ -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"})
|
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: name + ": template is not enabled or downloaded"})
|
||||||
return
|
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
|
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 {
|
if req.Containers[i].SnapshotLimit <= 0 {
|
||||||
req.Containers[i].SnapshotLimit = config.DefaultSnapshotLimit
|
req.Containers[i].SnapshotLimit = config.DefaultSnapshotLimit
|
||||||
|
|||||||
@@ -17,10 +17,32 @@ import (
|
|||||||
type PortMapping struct {
|
type PortMapping struct {
|
||||||
ContainerPort int `json:"container_port"`
|
ContainerPort int `json:"container_port"`
|
||||||
HostPort int `json:"host_port"`
|
HostPort int `json:"host_port"`
|
||||||
|
HostIP string `json:"host_ip,omitempty"`
|
||||||
Protocol string `json:"protocol"`
|
Protocol string `json:"protocol"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PublicIPv4Assignment struct {
|
||||||
|
Address string `json:"address"`
|
||||||
|
Interface string `json:"interface,omitempty"`
|
||||||
|
PrefixLen int `json:"prefix_len,omitempty"`
|
||||||
|
Gateway string `json:"gateway,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type IPv6Assignment struct {
|
||||||
|
Address string `json:"address"`
|
||||||
|
PrefixLen int `json:"prefix_len"`
|
||||||
|
Interface string `json:"interface,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PublicIPv6Prefix struct {
|
||||||
|
Address string `json:"address"`
|
||||||
|
Prefix string `json:"prefix,omitempty"`
|
||||||
|
PrefixLen int `json:"prefix_len"`
|
||||||
|
Interface string `json:"interface,omitempty"`
|
||||||
|
Gateway string `json:"gateway,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// SavedTask for persisting task queue across restarts
|
// SavedTask for persisting task queue across restarts
|
||||||
type SavedTask struct {
|
type SavedTask struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
@@ -91,9 +113,11 @@ type Container struct {
|
|||||||
IOSpeedMBps int `json:"io_speed_mbps"`
|
IOSpeedMBps int `json:"io_speed_mbps"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
IP string `json:"ip"`
|
IP string `json:"ip"`
|
||||||
|
PublicIPv4s []PublicIPv4Assignment `json:"public_ipv4s,omitempty"`
|
||||||
IPv6 string `json:"ipv6"`
|
IPv6 string `json:"ipv6"`
|
||||||
IPv6PrefixLen int `json:"ipv6_prefix_len"`
|
IPv6PrefixLen int `json:"ipv6_prefix_len"`
|
||||||
IPv6Interface string `json:"ipv6_interface"`
|
IPv6Interface string `json:"ipv6_interface"`
|
||||||
|
IPv6Addresses []IPv6Assignment `json:"ipv6_addresses,omitempty"`
|
||||||
VNCPort int `json:"vnc_port"`
|
VNCPort int `json:"vnc_port"`
|
||||||
SSHPort int `json:"ssh_port"`
|
SSHPort int `json:"ssh_port"`
|
||||||
SSHPassword string `json:"ssh_password"`
|
SSHPassword string `json:"ssh_password"`
|
||||||
@@ -136,6 +160,101 @@ func (c *Container) IsKVM() bool {
|
|||||||
return c.Runtime() == VirtualizationKVM
|
return c.Runtime() == VirtualizationKVM
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *Container) NormalizeNetworkAssignments() bool {
|
||||||
|
changed := false
|
||||||
|
seenIPv4 := map[string]bool{}
|
||||||
|
filteredIPv4 := make([]PublicIPv4Assignment, 0, len(c.PublicIPv4s))
|
||||||
|
for _, item := range c.PublicIPv4s {
|
||||||
|
item.Address = strings.TrimSpace(item.Address)
|
||||||
|
item.Interface = strings.TrimSpace(item.Interface)
|
||||||
|
item.Gateway = strings.TrimSpace(item.Gateway)
|
||||||
|
if item.Address == "" || seenIPv4[item.Address] {
|
||||||
|
if item.Address != "" {
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seenIPv4[item.Address] = true
|
||||||
|
filteredIPv4 = append(filteredIPv4, item)
|
||||||
|
}
|
||||||
|
if len(filteredIPv4) != len(c.PublicIPv4s) {
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
c.PublicIPv4s = filteredIPv4
|
||||||
|
|
||||||
|
seenIPv6 := map[string]bool{}
|
||||||
|
filteredIPv6 := make([]IPv6Assignment, 0, len(c.IPv6Addresses)+1)
|
||||||
|
for _, item := range c.IPv6Addresses {
|
||||||
|
item.Address = strings.TrimSpace(item.Address)
|
||||||
|
item.Interface = strings.TrimSpace(item.Interface)
|
||||||
|
if item.Address == "" || seenIPv6[item.Address] {
|
||||||
|
if item.Address != "" {
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seenIPv6[item.Address] = true
|
||||||
|
filteredIPv6 = append(filteredIPv6, item)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(c.IPv6) != "" && !seenIPv6[c.IPv6] {
|
||||||
|
filteredIPv6 = append([]IPv6Assignment{{
|
||||||
|
Address: c.IPv6,
|
||||||
|
PrefixLen: c.IPv6PrefixLen,
|
||||||
|
Interface: c.IPv6Interface,
|
||||||
|
}}, filteredIPv6...)
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if len(filteredIPv6) != len(c.IPv6Addresses) {
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
c.IPv6Addresses = filteredIPv6
|
||||||
|
if len(c.IPv6Addresses) > 0 {
|
||||||
|
first := c.IPv6Addresses[0]
|
||||||
|
if c.IPv6 != first.Address || c.IPv6PrefixLen != first.PrefixLen || c.IPv6Interface != first.Interface {
|
||||||
|
c.IPv6 = first.Address
|
||||||
|
c.IPv6PrefixLen = first.PrefixLen
|
||||||
|
c.IPv6Interface = first.Interface
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
} else if c.IPv6 != "" || c.IPv6PrefixLen != 0 || c.IPv6Interface != "" {
|
||||||
|
c.IPv6 = ""
|
||||||
|
c.IPv6PrefixLen = 0
|
||||||
|
c.IPv6Interface = ""
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
return changed
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Container) PublicIPv4Addresses() []string {
|
||||||
|
values := make([]string, 0, len(c.PublicIPv4s))
|
||||||
|
for _, item := range c.PublicIPv4s {
|
||||||
|
if item.Address != "" {
|
||||||
|
values = append(values, item.Address)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return values
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Container) PrimaryPublicIPv4() string {
|
||||||
|
if len(c.PublicIPv4s) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return c.PublicIPv4s[0].Address
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Container) IPv6AddressStrings() []string {
|
||||||
|
values := make([]string, 0, len(c.IPv6Addresses))
|
||||||
|
for _, item := range c.IPv6Addresses {
|
||||||
|
if item.Address != "" {
|
||||||
|
values = append(values, item.Address)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(values) == 0 && c.IPv6 != "" {
|
||||||
|
values = append(values, c.IPv6)
|
||||||
|
}
|
||||||
|
return values
|
||||||
|
}
|
||||||
|
|
||||||
// LxcName returns the internal LXC container name (ct-{id})
|
// LxcName returns the internal LXC container name (ct-{id})
|
||||||
func (c *Container) LxcName() string {
|
func (c *Container) LxcName() string {
|
||||||
if c.LXCName != "" {
|
if c.LXCName != "" {
|
||||||
@@ -242,6 +361,8 @@ type ClicdConfig struct {
|
|||||||
LoginLogs []SavedLoginLog `json:"login_logs"`
|
LoginLogs []SavedLoginLog `json:"login_logs"`
|
||||||
EnabledImages []string `json:"enabled_images"`
|
EnabledImages []string `json:"enabled_images"`
|
||||||
Snapshots []Snapshot `json:"snapshots"`
|
Snapshots []Snapshot `json:"snapshots"`
|
||||||
|
PublicIPv4Pool []PublicIPv4Assignment `json:"public_ipv4_pool"`
|
||||||
|
PublicIPv6Prefixes []PublicIPv6Prefix `json:"public_ipv6_prefixes"`
|
||||||
SecurityAutoShutdown bool `json:"security_auto_shutdown"`
|
SecurityAutoShutdown bool `json:"security_auto_shutdown"`
|
||||||
Language string `json:"language"`
|
Language string `json:"language"`
|
||||||
SSL SSLConfig `json:"ssl"`
|
SSL SSLConfig `json:"ssl"`
|
||||||
@@ -374,6 +495,8 @@ func InitConfig() (*ClicdConfig, error) {
|
|||||||
Tasks: []SavedTask{},
|
Tasks: []SavedTask{},
|
||||||
LoginLogs: []SavedLoginLog{},
|
LoginLogs: []SavedLoginLog{},
|
||||||
Snapshots: []Snapshot{},
|
Snapshots: []Snapshot{},
|
||||||
|
PublicIPv4Pool: []PublicIPv4Assignment{},
|
||||||
|
PublicIPv6Prefixes: []PublicIPv6Prefix{},
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := SaveConfig(); err != nil {
|
if err := SaveConfig(); err != nil {
|
||||||
@@ -424,6 +547,14 @@ func normalizeConfigDefaults(dataDir string) bool {
|
|||||||
AppConfig.Snapshots = make([]Snapshot, 0)
|
AppConfig.Snapshots = make([]Snapshot, 0)
|
||||||
changed = true
|
changed = true
|
||||||
}
|
}
|
||||||
|
if AppConfig.PublicIPv4Pool == nil {
|
||||||
|
AppConfig.PublicIPv4Pool = make([]PublicIPv4Assignment, 0)
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if AppConfig.PublicIPv6Prefixes == nil {
|
||||||
|
AppConfig.PublicIPv6Prefixes = make([]PublicIPv6Prefix, 0)
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
if AppConfig.SubUsers == nil {
|
if AppConfig.SubUsers == nil {
|
||||||
AppConfig.SubUsers = make([]SubUser, 0)
|
AppConfig.SubUsers = make([]SubUser, 0)
|
||||||
changed = true
|
changed = true
|
||||||
@@ -546,6 +677,9 @@ func migrateLoadedConfig() bool {
|
|||||||
if ensureContainerSnapshotLimits() {
|
if ensureContainerSnapshotLimits() {
|
||||||
changed = true
|
changed = true
|
||||||
}
|
}
|
||||||
|
if ensureContainerNetworkAssignments() {
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
if ensureContainerSnapshotScheduleDefaults() {
|
if ensureContainerSnapshotScheduleDefaults() {
|
||||||
changed = true
|
changed = true
|
||||||
}
|
}
|
||||||
@@ -608,13 +742,16 @@ func ensureContainerUUIDs() bool {
|
|||||||
func ensureContainerPortMappingLimits() bool {
|
func ensureContainerPortMappingLimits() bool {
|
||||||
changed := false
|
changed := false
|
||||||
for i := range AppConfig.Containers {
|
for i := range AppConfig.Containers {
|
||||||
if AppConfig.Containers[i].PortMappingLimit <= 0 {
|
if AppConfig.Containers[i].PortMappingLimit < 0 {
|
||||||
limit := len(AppConfig.Containers[i].PortMappings)
|
limit := len(AppConfig.Containers[i].PortMappings)
|
||||||
if limit < 2 {
|
if limit < 2 {
|
||||||
limit = 2
|
limit = 2
|
||||||
}
|
}
|
||||||
AppConfig.Containers[i].PortMappingLimit = limit
|
AppConfig.Containers[i].PortMappingLimit = limit
|
||||||
changed = true
|
changed = true
|
||||||
|
} else if AppConfig.Containers[i].PortMappingLimit == 0 && len(AppConfig.Containers[i].PortMappings) > 0 {
|
||||||
|
AppConfig.Containers[i].PortMappingLimit = len(AppConfig.Containers[i].PortMappings)
|
||||||
|
changed = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return changed
|
return changed
|
||||||
@@ -631,6 +768,16 @@ func ensureContainerSnapshotLimits() bool {
|
|||||||
return changed
|
return changed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ensureContainerNetworkAssignments() bool {
|
||||||
|
changed := false
|
||||||
|
for i := range AppConfig.Containers {
|
||||||
|
if AppConfig.Containers[i].NormalizeNetworkAssignments() {
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return changed
|
||||||
|
}
|
||||||
|
|
||||||
func migrateSubUsers() bool {
|
func migrateSubUsers() bool {
|
||||||
changed := false
|
changed := false
|
||||||
for i := range AppConfig.SubUsers {
|
for i := range AppConfig.SubUsers {
|
||||||
|
|||||||
@@ -35,8 +35,14 @@ type savedTaskConfig struct {
|
|||||||
IOSpeedMBps int `json:"io_speed_mbps"`
|
IOSpeedMBps int `json:"io_speed_mbps"`
|
||||||
ExtraPorts []int `json:"extra_ports"`
|
ExtraPorts []int `json:"extra_ports"`
|
||||||
PortMappingCount int `json:"port_mapping_count"`
|
PortMappingCount int `json:"port_mapping_count"`
|
||||||
|
AssignNAT *bool `json:"assign_nat,omitempty"`
|
||||||
SnapshotLimit int `json:"snapshot_limit"`
|
SnapshotLimit int `json:"snapshot_limit"`
|
||||||
|
AssignIPv4 bool `json:"assign_ipv4"`
|
||||||
|
IPv4Count int `json:"ipv4_count,omitempty"`
|
||||||
|
PublicIPv4s []string `json:"public_ipv4s,omitempty"`
|
||||||
AssignIPv6 bool `json:"assign_ipv6"`
|
AssignIPv6 bool `json:"assign_ipv6"`
|
||||||
|
IPv6Count int `json:"ipv6_count,omitempty"`
|
||||||
|
IPv6Addresses []string `json:"ipv6_addresses,omitempty"`
|
||||||
ExpiresAt string `json:"expires_at"`
|
ExpiresAt string `json:"expires_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,10 +181,28 @@ func ensureSchema() error {
|
|||||||
position INTEGER NOT NULL,
|
position INTEGER NOT NULL,
|
||||||
container_port INTEGER NOT NULL,
|
container_port INTEGER NOT NULL,
|
||||||
host_port INTEGER NOT NULL,
|
host_port INTEGER NOT NULL,
|
||||||
|
host_ip TEXT,
|
||||||
protocol TEXT,
|
protocol TEXT,
|
||||||
description TEXT,
|
description TEXT,
|
||||||
PRIMARY KEY (container_id, position)
|
PRIMARY KEY (container_id, position)
|
||||||
)`,
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS container_public_ipv4s (
|
||||||
|
container_id INTEGER NOT NULL,
|
||||||
|
position INTEGER NOT NULL,
|
||||||
|
address TEXT NOT NULL,
|
||||||
|
interface TEXT,
|
||||||
|
prefix_len INTEGER,
|
||||||
|
gateway TEXT,
|
||||||
|
PRIMARY KEY (container_id, position)
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS container_ipv6_addresses (
|
||||||
|
container_id INTEGER NOT NULL,
|
||||||
|
position INTEGER NOT NULL,
|
||||||
|
address TEXT NOT NULL,
|
||||||
|
prefix_len INTEGER,
|
||||||
|
interface TEXT,
|
||||||
|
PRIMARY KEY (container_id, position)
|
||||||
|
)`,
|
||||||
`CREATE TABLE IF NOT EXISTS sub_users (
|
`CREATE TABLE IF NOT EXISTS sub_users (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
username TEXT NOT NULL,
|
username TEXT NOT NULL,
|
||||||
@@ -253,8 +277,14 @@ func ensureSchema() error {
|
|||||||
cfg_traffic_out_gb INTEGER,
|
cfg_traffic_out_gb INTEGER,
|
||||||
cfg_io_speed_mbps INTEGER,
|
cfg_io_speed_mbps INTEGER,
|
||||||
cfg_port_mapping_count INTEGER,
|
cfg_port_mapping_count INTEGER,
|
||||||
|
cfg_assign_nat INTEGER,
|
||||||
cfg_snapshot_limit INTEGER,
|
cfg_snapshot_limit INTEGER,
|
||||||
|
cfg_assign_ipv4 INTEGER,
|
||||||
|
cfg_ipv4_count INTEGER,
|
||||||
|
cfg_public_ipv4s TEXT,
|
||||||
cfg_assign_ipv6 INTEGER,
|
cfg_assign_ipv6 INTEGER,
|
||||||
|
cfg_ipv6_count INTEGER,
|
||||||
|
cfg_ipv6_addresses TEXT,
|
||||||
cfg_expires_at TEXT
|
cfg_expires_at TEXT
|
||||||
)`,
|
)`,
|
||||||
`CREATE TABLE IF NOT EXISTS task_extra_ports (
|
`CREATE TABLE IF NOT EXISTS task_extra_ports (
|
||||||
@@ -308,6 +338,15 @@ func ensureSchemaMigrations() error {
|
|||||||
{"api_keys", "last_used_ip", "TEXT"},
|
{"api_keys", "last_used_ip", "TEXT"},
|
||||||
{"tasks", "ip", "TEXT"},
|
{"tasks", "ip", "TEXT"},
|
||||||
{"tasks", "user_agent", "TEXT"},
|
{"tasks", "user_agent", "TEXT"},
|
||||||
|
{"tasks", "cfg_assign_ipv4", "INTEGER"},
|
||||||
|
{"tasks", "cfg_ipv4_count", "INTEGER"},
|
||||||
|
{"tasks", "cfg_public_ipv4s", "TEXT"},
|
||||||
|
{"tasks", "cfg_assign_nat", "INTEGER"},
|
||||||
|
{"tasks", "cfg_ipv6_count", "INTEGER"},
|
||||||
|
{"tasks", "cfg_ipv6_addresses", "TEXT"},
|
||||||
|
{"port_mappings", "host_ip", "TEXT"},
|
||||||
|
{"container_public_ipv4s", "prefix_len", "INTEGER"},
|
||||||
|
{"container_public_ipv4s", "gateway", "TEXT"},
|
||||||
} {
|
} {
|
||||||
if err := ensureColumn(column.table, column.name, column.def); err != nil {
|
if err := ensureColumn(column.table, column.name, column.def); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -381,6 +420,12 @@ func loadConfigFromDB() (*ClicdConfig, bool, error) {
|
|||||||
if raw := strings.TrimSpace(meta["ssl_certificates"]); raw != "" {
|
if raw := strings.TrimSpace(meta["ssl_certificates"]); raw != "" {
|
||||||
_ = json.Unmarshal([]byte(raw), &cfg.SSLCertificates)
|
_ = json.Unmarshal([]byte(raw), &cfg.SSLCertificates)
|
||||||
}
|
}
|
||||||
|
if raw := strings.TrimSpace(meta["public_ipv4_pool"]); raw != "" {
|
||||||
|
_ = json.Unmarshal([]byte(raw), &cfg.PublicIPv4Pool)
|
||||||
|
}
|
||||||
|
if raw := strings.TrimSpace(meta["public_ipv6_prefixes"]); raw != "" {
|
||||||
|
_ = json.Unmarshal([]byte(raw), &cfg.PublicIPv6Prefixes)
|
||||||
|
}
|
||||||
|
|
||||||
if cfg.Containers, err = loadContainers(); err != nil {
|
if cfg.Containers, err = loadContainers(); err != nil {
|
||||||
return nil, false, err
|
return nil, false, err
|
||||||
@@ -424,6 +469,8 @@ func saveConfigToDB() error {
|
|||||||
|
|
||||||
for _, table := range []string{
|
for _, table := range []string{
|
||||||
"port_mappings",
|
"port_mappings",
|
||||||
|
"container_public_ipv4s",
|
||||||
|
"container_ipv6_addresses",
|
||||||
"sub_user_container_names",
|
"sub_user_container_names",
|
||||||
"sub_user_container_uuids",
|
"sub_user_container_uuids",
|
||||||
"containers",
|
"containers",
|
||||||
@@ -475,6 +522,8 @@ func saveConfigToDB() error {
|
|||||||
func saveMeta(tx *sql.Tx) error {
|
func saveMeta(tx *sql.Tx) error {
|
||||||
sslJSON, _ := json.Marshal(AppConfig.SSL)
|
sslJSON, _ := json.Marshal(AppConfig.SSL)
|
||||||
sslCertificatesJSON, _ := json.Marshal(AppConfig.SSLCertificates)
|
sslCertificatesJSON, _ := json.Marshal(AppConfig.SSLCertificates)
|
||||||
|
publicIPv4PoolJSON, _ := json.Marshal(AppConfig.PublicIPv4Pool)
|
||||||
|
publicIPv6PrefixesJSON, _ := json.Marshal(AppConfig.PublicIPv6Prefixes)
|
||||||
values := map[string]string{
|
values := map[string]string{
|
||||||
"admin_user": AppConfig.AdminUser,
|
"admin_user": AppConfig.AdminUser,
|
||||||
"admin_pass_hash": AppConfig.AdminPassHash,
|
"admin_pass_hash": AppConfig.AdminPassHash,
|
||||||
@@ -489,6 +538,8 @@ func saveMeta(tx *sql.Tx) error {
|
|||||||
"language": NormalizeLanguage(AppConfig.Language),
|
"language": NormalizeLanguage(AppConfig.Language),
|
||||||
"ssl": string(sslJSON),
|
"ssl": string(sslJSON),
|
||||||
"ssl_certificates": string(sslCertificatesJSON),
|
"ssl_certificates": string(sslCertificatesJSON),
|
||||||
|
"public_ipv4_pool": string(publicIPv4PoolJSON),
|
||||||
|
"public_ipv6_prefixes": string(publicIPv6PrefixesJSON),
|
||||||
"schema_version": "1",
|
"schema_version": "1",
|
||||||
"updated_at": time.Now().Format("2006-01-02 15:04:05"),
|
"updated_at": time.Now().Format("2006-01-02 15:04:05"),
|
||||||
}
|
}
|
||||||
@@ -524,8 +575,20 @@ func saveContainers(tx *sql.Tx) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
for i, pm := range c.PortMappings {
|
for i, pm := range c.PortMappings {
|
||||||
if _, err := tx.Exec(`INSERT INTO port_mappings(container_id, position, container_port, host_port, protocol, description)
|
if _, err := tx.Exec(`INSERT INTO port_mappings(container_id, position, container_port, host_port, host_ip, protocol, description)
|
||||||
VALUES (?, ?, ?, ?, ?, ?)`, c.ID, i, pm.ContainerPort, pm.HostPort, pm.Protocol, pm.Description); err != nil {
|
VALUES (?, ?, ?, ?, ?, ?, ?)`, c.ID, i, pm.ContainerPort, pm.HostPort, pm.HostIP, pm.Protocol, pm.Description); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i, ip := range c.PublicIPv4s {
|
||||||
|
if _, err := tx.Exec(`INSERT INTO container_public_ipv4s(container_id, position, address, interface, prefix_len, gateway)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)`, c.ID, i, ip.Address, ip.Interface, ip.PrefixLen, ip.Gateway); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i, ip := range c.IPv6Addresses {
|
||||||
|
if _, err := tx.Exec(`INSERT INTO container_ipv6_addresses(container_id, position, address, prefix_len, interface)
|
||||||
|
VALUES (?, ?, ?, ?, ?)`, c.ID, i, ip.Address, ip.PrefixLen, ip.Interface); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -590,14 +653,16 @@ func saveTasksDB(tx *sql.Tx) error {
|
|||||||
id, type, container_id, container_name, status, error, created_at, template_id, user, ip, user_agent,
|
id, type, container_id, container_name, status, error, created_at, template_id, user, ip, user_agent,
|
||||||
cfg_name, cfg_virtualization, cfg_template_id, cfg_vcpu, cfg_cpu_percent, cfg_ram_mb, cfg_disk_gb,
|
cfg_name, cfg_virtualization, cfg_template_id, cfg_vcpu, cfg_cpu_percent, cfg_ram_mb, cfg_disk_gb,
|
||||||
cfg_network_bw_mbps, cfg_monthly_traffic_gb, cfg_traffic_mode, cfg_traffic_in_gb,
|
cfg_network_bw_mbps, cfg_monthly_traffic_gb, cfg_traffic_mode, cfg_traffic_in_gb,
|
||||||
cfg_traffic_out_gb, cfg_io_speed_mbps, cfg_port_mapping_count, cfg_snapshot_limit,
|
cfg_traffic_out_gb, cfg_io_speed_mbps, cfg_port_mapping_count, cfg_assign_nat, cfg_snapshot_limit,
|
||||||
cfg_assign_ipv6, cfg_expires_at
|
cfg_assign_ipv4, cfg_ipv4_count, cfg_public_ipv4s, cfg_assign_ipv6, cfg_ipv6_count, cfg_ipv6_addresses,
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
cfg_expires_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
task.ID, task.Type, task.ContainerID, task.ContainerName, task.Status, task.Error, task.CreatedAt, task.TemplateID, task.User, task.IP, task.UserAgent,
|
task.ID, task.Type, task.ContainerID, task.ContainerName, task.Status, task.Error, task.CreatedAt, task.TemplateID, task.User, task.IP, task.UserAgent,
|
||||||
cfg.Name, cfg.Virtualization, cfg.TemplateID, cfg.VCPU, cfg.CPUPercent, cfg.RAMMB, cfg.DiskGB,
|
cfg.Name, cfg.Virtualization, cfg.TemplateID, cfg.VCPU, cfg.CPUPercent, cfg.RAMMB, cfg.DiskGB,
|
||||||
cfg.NetworkBWMbps, cfg.MonthlyTrafficGB, cfg.TrafficMode, cfg.TrafficInGB,
|
cfg.NetworkBWMbps, cfg.MonthlyTrafficGB, cfg.TrafficMode, cfg.TrafficInGB,
|
||||||
cfg.TrafficOutGB, cfg.IOSpeedMBps, cfg.PortMappingCount, cfg.SnapshotLimit,
|
cfg.TrafficOutGB, cfg.IOSpeedMBps, cfg.PortMappingCount, boolPtrInt(cfg.AssignNAT), cfg.SnapshotLimit,
|
||||||
boolInt(cfg.AssignIPv6), cfg.ExpiresAt,
|
boolInt(cfg.AssignIPv4), cfg.IPv4Count, encodeStringSlice(cfg.PublicIPv4s),
|
||||||
|
boolInt(cfg.AssignIPv6), cfg.IPv6Count, encodeStringSlice(cfg.IPv6Addresses), cfg.ExpiresAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -686,12 +751,21 @@ func loadContainers() ([]Container, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
result[i].PublicIPv4s, err = loadContainerPublicIPv4s(result[i].ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result[i].IPv6Addresses, err = loadContainerIPv6Addresses(result[i].ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result[i].NormalizeNetworkAssignments()
|
||||||
}
|
}
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadPortMappings(containerID int) ([]PortMapping, error) {
|
func loadPortMappings(containerID int) ([]PortMapping, error) {
|
||||||
rows, err := db.Query(`SELECT container_port, host_port, protocol, description FROM port_mappings WHERE container_id = ? ORDER BY position`, containerID)
|
rows, err := db.Query(`SELECT container_port, host_port, host_ip, protocol, description FROM port_mappings WHERE container_id = ? ORDER BY position`, containerID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -699,14 +773,64 @@ func loadPortMappings(containerID int) ([]PortMapping, error) {
|
|||||||
result := []PortMapping{}
|
result := []PortMapping{}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var pm PortMapping
|
var pm PortMapping
|
||||||
if err := rows.Scan(&pm.ContainerPort, &pm.HostPort, &pm.Protocol, &pm.Description); err != nil {
|
var hostIP sql.NullString
|
||||||
|
if err := rows.Scan(&pm.ContainerPort, &pm.HostPort, &hostIP, &pm.Protocol, &pm.Description); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
pm.HostIP = hostIP.String
|
||||||
result = append(result, pm)
|
result = append(result, pm)
|
||||||
}
|
}
|
||||||
return result, rows.Err()
|
return result, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func loadContainerPublicIPv4s(containerID int) ([]PublicIPv4Assignment, error) {
|
||||||
|
rows, err := db.Query(`SELECT address, interface, prefix_len, gateway FROM container_public_ipv4s WHERE container_id = ? ORDER BY position`, containerID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
result := []PublicIPv4Assignment{}
|
||||||
|
for rows.Next() {
|
||||||
|
var item PublicIPv4Assignment
|
||||||
|
var iface sql.NullString
|
||||||
|
var prefixLen sql.NullInt64
|
||||||
|
var gateway sql.NullString
|
||||||
|
if err := rows.Scan(&item.Address, &iface, &prefixLen, &gateway); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
item.Interface = iface.String
|
||||||
|
if prefixLen.Valid {
|
||||||
|
item.PrefixLen = int(prefixLen.Int64)
|
||||||
|
}
|
||||||
|
item.Gateway = gateway.String
|
||||||
|
result = append(result, item)
|
||||||
|
}
|
||||||
|
return result, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadContainerIPv6Addresses(containerID int) ([]IPv6Assignment, error) {
|
||||||
|
rows, err := db.Query(`SELECT address, prefix_len, interface FROM container_ipv6_addresses WHERE container_id = ? ORDER BY position`, containerID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
result := []IPv6Assignment{}
|
||||||
|
for rows.Next() {
|
||||||
|
var item IPv6Assignment
|
||||||
|
var prefixLen sql.NullInt64
|
||||||
|
var iface sql.NullString
|
||||||
|
if err := rows.Scan(&item.Address, &prefixLen, &iface); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if prefixLen.Valid {
|
||||||
|
item.PrefixLen = int(prefixLen.Int64)
|
||||||
|
}
|
||||||
|
item.Interface = iface.String
|
||||||
|
result = append(result, item)
|
||||||
|
}
|
||||||
|
return result, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
func loadSubUsers() ([]SubUser, error) {
|
func loadSubUsers() ([]SubUser, error) {
|
||||||
rows, err := db.Query(`SELECT id, username, password, pass_hash, access_code, created_at, token_version FROM sub_users ORDER BY created_at, id`)
|
rows, err := db.Query(`SELECT id, username, password, pass_hash, access_code, created_at, token_version FROM sub_users ORDER BY created_at, id`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -808,8 +932,9 @@ func loadTasks() ([]SavedTask, error) {
|
|||||||
id, type, container_id, container_name, status, error, created_at, template_id, user, ip, user_agent,
|
id, type, container_id, container_name, status, error, created_at, template_id, user, ip, user_agent,
|
||||||
cfg_name, cfg_virtualization, cfg_template_id, cfg_vcpu, cfg_cpu_percent, cfg_ram_mb, cfg_disk_gb,
|
cfg_name, cfg_virtualization, cfg_template_id, cfg_vcpu, cfg_cpu_percent, cfg_ram_mb, cfg_disk_gb,
|
||||||
cfg_network_bw_mbps, cfg_monthly_traffic_gb, cfg_traffic_mode, cfg_traffic_in_gb,
|
cfg_network_bw_mbps, cfg_monthly_traffic_gb, cfg_traffic_mode, cfg_traffic_in_gb,
|
||||||
cfg_traffic_out_gb, cfg_io_speed_mbps, cfg_port_mapping_count, cfg_snapshot_limit,
|
cfg_traffic_out_gb, cfg_io_speed_mbps, cfg_port_mapping_count, cfg_assign_nat, cfg_snapshot_limit,
|
||||||
cfg_assign_ipv6, cfg_expires_at
|
cfg_assign_ipv4, cfg_ipv4_count, cfg_public_ipv4s, cfg_assign_ipv6, cfg_ipv6_count, cfg_ipv6_addresses,
|
||||||
|
cfg_expires_at
|
||||||
FROM tasks ORDER BY created_at, id`)
|
FROM tasks ORDER BY created_at, id`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -820,20 +945,34 @@ func loadTasks() ([]SavedTask, error) {
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var t SavedTask
|
var t SavedTask
|
||||||
var cfg savedTaskConfig
|
var cfg savedTaskConfig
|
||||||
var assignIPv6 int
|
var assignIPv4, assignIPv6 int
|
||||||
var ip, userAgent sql.NullString
|
var ip, userAgent, publicIPv4s, ipv6Addresses sql.NullString
|
||||||
|
var assignNAT, ipv4Count, ipv6Count sql.NullInt64
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&t.ID, &t.Type, &t.ContainerID, &t.ContainerName, &t.Status, &t.Error, &t.CreatedAt, &t.TemplateID, &t.User, &ip, &userAgent,
|
&t.ID, &t.Type, &t.ContainerID, &t.ContainerName, &t.Status, &t.Error, &t.CreatedAt, &t.TemplateID, &t.User, &ip, &userAgent,
|
||||||
&cfg.Name, &cfg.Virtualization, &cfg.TemplateID, &cfg.VCPU, &cfg.CPUPercent, &cfg.RAMMB, &cfg.DiskGB,
|
&cfg.Name, &cfg.Virtualization, &cfg.TemplateID, &cfg.VCPU, &cfg.CPUPercent, &cfg.RAMMB, &cfg.DiskGB,
|
||||||
&cfg.NetworkBWMbps, &cfg.MonthlyTrafficGB, &cfg.TrafficMode, &cfg.TrafficInGB,
|
&cfg.NetworkBWMbps, &cfg.MonthlyTrafficGB, &cfg.TrafficMode, &cfg.TrafficInGB,
|
||||||
&cfg.TrafficOutGB, &cfg.IOSpeedMBps, &cfg.PortMappingCount, &cfg.SnapshotLimit,
|
&cfg.TrafficOutGB, &cfg.IOSpeedMBps, &cfg.PortMappingCount, &assignNAT, &cfg.SnapshotLimit,
|
||||||
&assignIPv6, &cfg.ExpiresAt,
|
&assignIPv4, &ipv4Count, &publicIPv4s, &assignIPv6, &ipv6Count, &ipv6Addresses, &cfg.ExpiresAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
t.IP = ip.String
|
t.IP = ip.String
|
||||||
t.UserAgent = userAgent.String
|
t.UserAgent = userAgent.String
|
||||||
|
if assignNAT.Valid {
|
||||||
|
value := assignNAT.Int64 != 0
|
||||||
|
cfg.AssignNAT = &value
|
||||||
|
}
|
||||||
|
cfg.AssignIPv4 = assignIPv4 != 0
|
||||||
|
if ipv4Count.Valid {
|
||||||
|
cfg.IPv4Count = int(ipv4Count.Int64)
|
||||||
|
}
|
||||||
|
cfg.PublicIPv4s = decodeStringSlice(publicIPv4s.String)
|
||||||
cfg.AssignIPv6 = assignIPv6 != 0
|
cfg.AssignIPv6 = assignIPv6 != 0
|
||||||
|
if ipv6Count.Valid {
|
||||||
|
cfg.IPv6Count = int(ipv6Count.Int64)
|
||||||
|
}
|
||||||
|
cfg.IPv6Addresses = decodeStringSlice(ipv6Addresses.String)
|
||||||
result = append(result, t)
|
result = append(result, t)
|
||||||
configs = append(configs, cfg)
|
configs = append(configs, cfg)
|
||||||
}
|
}
|
||||||
@@ -947,6 +1086,13 @@ func boolInt(value bool) int {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func boolPtrInt(value *bool) interface{} {
|
||||||
|
if value == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return boolInt(*value)
|
||||||
|
}
|
||||||
|
|
||||||
func btoa(value bool) string {
|
func btoa(value bool) string {
|
||||||
if value {
|
if value {
|
||||||
return "1"
|
return "1"
|
||||||
|
|||||||
+251
-75
@@ -364,8 +364,11 @@ func (m *Manager) CreateContainer(cfg lxc.ContainerConfig) error {
|
|||||||
if cfg.VCPU < 1 || cfg.VCPU != float64(int(cfg.VCPU)) {
|
if cfg.VCPU < 1 || cfg.VCPU != float64(int(cfg.VCPU)) {
|
||||||
return fmt.Errorf("KVM vCPU must be a whole number and at least 1")
|
return fmt.Errorf("KVM vCPU must be a whole number and at least 1")
|
||||||
}
|
}
|
||||||
if cfg.PortMappingCount < 2 {
|
if cfg.WantsNAT() && cfg.PortMappingCount < 2 {
|
||||||
cfg.PortMappingCount = 2
|
cfg.PortMappingCount = 2
|
||||||
|
} else if !cfg.WantsNAT() {
|
||||||
|
cfg.PortMappingCount = 0
|
||||||
|
cfg.ExtraPorts = nil
|
||||||
}
|
}
|
||||||
if cfg.SnapshotLimit <= 0 {
|
if cfg.SnapshotLimit <= 0 {
|
||||||
cfg.SnapshotLimit = config.DefaultSnapshotLimit
|
cfg.SnapshotLimit = config.DefaultSnapshotLimit
|
||||||
@@ -403,18 +406,21 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig
|
|||||||
seedPath := filepath.Join(m.instanceDir(vmName), "seed.iso")
|
seedPath := filepath.Join(m.instanceDir(vmName), "seed.iso")
|
||||||
mac := randomMAC()
|
mac := randomMAC()
|
||||||
sshPassword := generateRandomString(16)
|
sshPassword := generateRandomString(16)
|
||||||
ipv6 := ""
|
publicIPv4s, err := lxc.AllocatePublicIPv4Assignments(id, cfg.PublicIPv4s, cfg.IPv4Count, cfg.AssignIPv4)
|
||||||
ipv6PrefixLen := 0
|
|
||||||
ipv6Interface := ""
|
|
||||||
if cfg.AssignIPv6 {
|
|
||||||
assigned, prefixLen, iface, err := m.allocateIPv6ForContainer(id)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
ipv6 = assigned
|
|
||||||
ipv6PrefixLen = prefixLen
|
ipv6Assignments := []config.IPv6Assignment{}
|
||||||
ipv6Interface = iface
|
if cfg.AssignIPv6 || len(cfg.IPv6Addresses) > 0 {
|
||||||
|
assigned, err := m.allocateIPv6AssignmentsForContainer(id, cfg.IPv6Addresses, cfg.IPv6Count, true)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
|
ipv6Assignments = assigned
|
||||||
|
}
|
||||||
|
ipv6List := configIPv6AssignmentAddresses(ipv6Assignments)
|
||||||
|
defaultHostIP := lxc.DefaultPortMappingHostIP(publicIPv4s)
|
||||||
|
|
||||||
var xml string
|
var xml string
|
||||||
winAdminPassword := ""
|
winAdminPassword := ""
|
||||||
@@ -433,7 +439,7 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig
|
|||||||
}
|
}
|
||||||
winAdminPassword = generateWindowsPassword()
|
winAdminPassword = generateWindowsPassword()
|
||||||
unattendPath := filepath.Join(m.instanceDir(vmName), "unattend.iso")
|
unattendPath := filepath.Join(m.instanceDir(vmName), "unattend.iso")
|
||||||
if err := createWindowsUnattendISO(unattendPath, cfg.Name, winAdminPassword, ipv6); err != nil {
|
if err := createWindowsUnattendISO(unattendPath, cfg.Name, winAdminPassword, ipv6List); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
xml = windowsDomainXML(vmName, int(cfg.VCPU), cfg.RAMMB, diskPath, ImagePath(image.ID), unattendPath, mac, cfg.IOSpeedMBps, cfg.NetworkBWMbps)
|
xml = windowsDomainXML(vmName, int(cfg.VCPU), cfg.RAMMB, diskPath, ImagePath(image.ID), unattendPath, mac, cfg.IOSpeedMBps, cfg.NetworkBWMbps)
|
||||||
@@ -449,7 +455,7 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig
|
|||||||
if err := createOverlayDisk(ImagePath(image.ID), diskPath, cfg.DiskGB); err != nil {
|
if err := createOverlayDisk(ImagePath(image.ID), diskPath, cfg.DiskGB); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := createSeedISO(seedPath, vmName, cfg.Name, sshPassword, mac, ipv6, *image); err != nil {
|
if err := createSeedISO(seedPath, vmName, cfg.Name, sshPassword, mac, ipv6List, *image); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
xml = domainXML(vmName, int(cfg.VCPU), cfg.RAMMB, diskPath, seedPath, mac, cfg.IOSpeedMBps, cfg.NetworkBWMbps, image.Desktop != "")
|
xml = domainXML(vmName, int(cfg.VCPU), cfg.RAMMB, diskPath, seedPath, mac, cfg.IOSpeedMBps, cfg.NetworkBWMbps, image.Desktop != "")
|
||||||
@@ -465,20 +471,26 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig
|
|||||||
|
|
||||||
sshPort := 0
|
sshPort := 0
|
||||||
portMappings := []config.PortMapping{}
|
portMappings := []config.PortMapping{}
|
||||||
if allocatePorts {
|
if allocatePorts && cfg.WantsNAT() {
|
||||||
sshPort = config.AllocateSSHPort()
|
sshPort = config.AllocateSSHPort()
|
||||||
if IsWindowsImage(image.ID) {
|
if IsWindowsImage(image.ID) {
|
||||||
// Windows: RDP (3389) instead of SSH (22)
|
// Windows: RDP (3389) instead of SSH (22)
|
||||||
portMappings = []config.PortMapping{{
|
portMappings = []config.PortMapping{{
|
||||||
ContainerPort: 3389,
|
ContainerPort: 3389,
|
||||||
HostPort: sshPort,
|
HostPort: sshPort,
|
||||||
|
HostIP: defaultHostIP,
|
||||||
Protocol: "tcp",
|
Protocol: "tcp",
|
||||||
Description: "RDP",
|
Description: "RDP",
|
||||||
}}
|
}}
|
||||||
} else {
|
} else {
|
||||||
portMappings = lxc.SetupDefaultPortMappings(sshPort)
|
portMappings = lxc.SetupDefaultPortMappings(sshPort)
|
||||||
|
if defaultHostIP != "" {
|
||||||
|
for i := range portMappings {
|
||||||
|
portMappings[i].HostIP = defaultHostIP
|
||||||
}
|
}
|
||||||
tempC := &config.Container{PortMappings: portMappings}
|
}
|
||||||
|
}
|
||||||
|
tempC := &config.Container{ID: id, PublicIPv4s: publicIPv4s, PortMappings: portMappings}
|
||||||
extraPorts := cfg.ExtraPorts
|
extraPorts := cfg.ExtraPorts
|
||||||
if len(extraPorts) == 0 && cfg.PortMappingCount > 1 {
|
if len(extraPorts) == 0 && cfg.PortMappingCount > 1 {
|
||||||
extraPorts = allocateDefaultEqualPorts(tempC, cfg.PortMappingCount-1)
|
extraPorts = allocateDefaultEqualPorts(tempC, cfg.PortMappingCount-1)
|
||||||
@@ -490,6 +502,7 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig
|
|||||||
tempC.PortMappings = append(tempC.PortMappings, config.PortMapping{
|
tempC.PortMappings = append(tempC.PortMappings, config.PortMapping{
|
||||||
ContainerPort: port,
|
ContainerPort: port,
|
||||||
HostPort: port,
|
HostPort: port,
|
||||||
|
HostIP: defaultHostIP,
|
||||||
Protocol: "tcp",
|
Protocol: "tcp",
|
||||||
Description: fmt.Sprintf("Port-%d", port),
|
Description: fmt.Sprintf("Port-%d", port),
|
||||||
})
|
})
|
||||||
@@ -502,7 +515,7 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig
|
|||||||
if trafficMode == "" {
|
if trafficMode == "" {
|
||||||
trafficMode = "total"
|
trafficMode = "total"
|
||||||
}
|
}
|
||||||
return &config.Container{
|
container := &config.Container{
|
||||||
ID: id,
|
ID: id,
|
||||||
UUID: config.NewContainerUUID(),
|
UUID: config.NewContainerUUID(),
|
||||||
Name: cfg.Name,
|
Name: cfg.Name,
|
||||||
@@ -521,9 +534,8 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig
|
|||||||
TrafficOutGB: cfg.TrafficOutGB,
|
TrafficOutGB: cfg.TrafficOutGB,
|
||||||
TrafficResetDate: now[:7],
|
TrafficResetDate: now[:7],
|
||||||
IOSpeedMBps: cfg.IOSpeedMBps,
|
IOSpeedMBps: cfg.IOSpeedMBps,
|
||||||
IPv6: ipv6,
|
PublicIPv4s: publicIPv4s,
|
||||||
IPv6PrefixLen: ipv6PrefixLen,
|
IPv6Addresses: ipv6Assignments,
|
||||||
IPv6Interface: ipv6Interface,
|
|
||||||
Status: "stopped",
|
Status: "stopped",
|
||||||
SSHPort: sshPort,
|
SSHPort: sshPort,
|
||||||
SSHPassword: func() string {
|
SSHPassword: func() string {
|
||||||
@@ -537,7 +549,9 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig
|
|||||||
SnapshotLimit: config.NormalizeSnapshotLimit(cfg.SnapshotLimit),
|
SnapshotLimit: config.NormalizeSnapshotLimit(cfg.SnapshotLimit),
|
||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
ExpiresAt: cfg.ExpiresAt,
|
ExpiresAt: cfg.ExpiresAt,
|
||||||
}, nil
|
}
|
||||||
|
container.NormalizeNetworkAssignments()
|
||||||
|
return container, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) StartContainer(id int) error {
|
func (m *Manager) StartContainer(id int) error {
|
||||||
@@ -548,6 +562,7 @@ func (m *Manager) StartContainer(id int) error {
|
|||||||
if err := m.validateHost(IsWindowsImage(c.Template)); err != nil {
|
if err := m.validateHost(IsWindowsImage(c.Template)); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
lxc.EnsureAssignedPublicIPv4s(c.PublicIPv4s)
|
||||||
name := c.VirshName()
|
name := c.VirshName()
|
||||||
if err := m.ensureDomainDefinition(c); err != nil {
|
if err := m.ensureDomainDefinition(c); err != nil {
|
||||||
fmt.Printf("Warning: failed to refresh KVM domain definition for %s: %v\n", name, err)
|
fmt.Printf("Warning: failed to refresh KVM domain definition for %s: %v\n", name, err)
|
||||||
@@ -598,7 +613,7 @@ func (m *Manager) StartContainer(id int) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if c.IPv6 != "" {
|
if c.IPv6 != "" || len(c.IPv6Addresses) > 0 {
|
||||||
if err := m.applyIPv6Runtime(c); err != nil {
|
if err := m.applyIPv6Runtime(c); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -1559,7 +1574,7 @@ func createEmptyDisk(target string, diskGB int) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func createWindowsUnattendISO(target, hostname, adminPassword, ipv6 string) error {
|
func createWindowsUnattendISO(target, hostname, adminPassword string, ipv6s []string) error {
|
||||||
tool := firstAvailableCommand("genisoimage", "mkisofs", "xorriso")
|
tool := firstAvailableCommand("genisoimage", "mkisofs", "xorriso")
|
||||||
if tool == "" {
|
if tool == "" {
|
||||||
return fmt.Errorf("one of genisoimage, mkisofs, xorriso is required for Windows unattended setup")
|
return fmt.Errorf("one of genisoimage, mkisofs, xorriso is required for Windows unattended setup")
|
||||||
@@ -1585,13 +1600,13 @@ func createWindowsUnattendISO(target, hostname, adminPassword, ipv6 string) erro
|
|||||||
if err := os.WriteFile(filepath.Join(setupScriptsDir, "SetupComplete.cmd"), []byte(windowsSetupCompleteCMD()), 0600); err != nil {
|
if err := os.WriteFile(filepath.Join(setupScriptsDir, "SetupComplete.cmd"), []byte(windowsSetupCompleteCMD()), 0600); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(filepath.Join(clicdDir, "FirstLogon.ps1"), []byte(windowsFirstLogonPowerShell(adminPassword, ipv6)), 0600); err != nil {
|
if err := os.WriteFile(filepath.Join(clicdDir, "FirstLogon.ps1"), []byte(windowsFirstLogonPowerShell(adminPassword, ipv6s)), 0600); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(filepath.Join(dir, "SetupComplete.cmd"), []byte(windowsSetupCompleteCMD()), 0600); err != nil {
|
if err := os.WriteFile(filepath.Join(dir, "SetupComplete.cmd"), []byte(windowsSetupCompleteCMD()), 0600); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(filepath.Join(dir, "FirstLogon.ps1"), []byte(windowsFirstLogonPowerShell(adminPassword, ipv6)), 0600); err != nil {
|
if err := os.WriteFile(filepath.Join(dir, "FirstLogon.ps1"), []byte(windowsFirstLogonPowerShell(adminPassword, ipv6s)), 0600); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
_ = os.Remove(target)
|
_ = os.Remove(target)
|
||||||
@@ -1701,7 +1716,7 @@ exit /b 0
|
|||||||
`
|
`
|
||||||
}
|
}
|
||||||
|
|
||||||
func windowsFirstLogonPowerShell(adminPassword, ipv6 string) string {
|
func windowsFirstLogonPowerShell(adminPassword string, ipv6s []string) string {
|
||||||
commands := []string{
|
commands := []string{
|
||||||
"$ErrorActionPreference='Continue'",
|
"$ErrorActionPreference='Continue'",
|
||||||
"$ProgressPreference='SilentlyContinue'",
|
"$ProgressPreference='SilentlyContinue'",
|
||||||
@@ -1731,9 +1746,10 @@ func windowsFirstLogonPowerShell(adminPassword, ipv6 string) string {
|
|||||||
"Get-Service QEMU-GA,qemu-ga -ErrorAction SilentlyContinue | Set-Service -StartupType Automatic",
|
"Get-Service QEMU-GA,qemu-ga -ErrorAction SilentlyContinue | Set-Service -StartupType Automatic",
|
||||||
"Start-Service QEMU-GA,qemu-ga -ErrorAction SilentlyContinue",
|
"Start-Service QEMU-GA,qemu-ga -ErrorAction SilentlyContinue",
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(ipv6) != "" {
|
ipv6s = normalizeKVMIPv6List(ipv6s)
|
||||||
|
if len(ipv6s) > 0 {
|
||||||
commands = append(commands,
|
commands = append(commands,
|
||||||
windowsIPv6PowerShell(strings.TrimSpace(ipv6)),
|
windowsIPv6PowerShell(ipv6s),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
commands = append(commands,
|
commands = append(commands,
|
||||||
@@ -1743,17 +1759,24 @@ func windowsFirstLogonPowerShell(adminPassword, ipv6 string) string {
|
|||||||
return strings.Join(commands, "\r\n") + "\r\n"
|
return strings.Join(commands, "\r\n") + "\r\n"
|
||||||
}
|
}
|
||||||
|
|
||||||
func windowsIPv6PowerShell(ipv6 string) string {
|
func windowsIPv6PowerShell(ipv6s []string) string {
|
||||||
ipv6 = strings.TrimSpace(ipv6)
|
ipv6s = normalizeKVMIPv6List(ipv6s)
|
||||||
if ipv6 == "" {
|
if len(ipv6s) == 0 {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
quoted := make([]string, 0, len(ipv6s))
|
||||||
|
for _, ipv6 := range ipv6s {
|
||||||
|
quoted = append(quoted, "'"+strings.ReplaceAll(ipv6, "'", "''")+"'")
|
||||||
|
}
|
||||||
return strings.Join([]string{
|
return strings.Join([]string{
|
||||||
|
"$clicdIPv6=@(" + strings.Join(quoted, ",") + ")",
|
||||||
"$iface=$null",
|
"$iface=$null",
|
||||||
"for ($i=0; $i -lt 60 -and -not $iface; $i++) { $iface=Get-NetAdapter | Where-Object { $_.Status -eq 'Up' -and $_.HardwareInterface } | Sort-Object ifIndex | Select-Object -First 1; if (-not $iface) { Start-Sleep -Seconds 5 } }",
|
"for ($i=0; $i -lt 60 -and -not $iface; $i++) { $iface=Get-NetAdapter | Where-Object { $_.Status -eq 'Up' -and $_.HardwareInterface } | Sort-Object ifIndex | Select-Object -First 1; if (-not $iface) { Start-Sleep -Seconds 5 } }",
|
||||||
"if ($iface) {",
|
"if ($iface) {",
|
||||||
" Get-NetIPAddress -InterfaceIndex $iface.ifIndex -AddressFamily IPv6 -ErrorAction SilentlyContinue | Where-Object { $_.IPAddress -eq '" + ipv6 + "' } | Remove-NetIPAddress -Confirm:$false -ErrorAction SilentlyContinue",
|
" foreach ($ip in $clicdIPv6) {",
|
||||||
" New-NetIPAddress -IPAddress '" + ipv6 + "' -PrefixLength 128 -InterfaceIndex $iface.ifIndex -SkipAsSource:$false -ErrorAction SilentlyContinue | Out-Null",
|
" Get-NetIPAddress -InterfaceIndex $iface.ifIndex -AddressFamily IPv6 -ErrorAction SilentlyContinue | Where-Object { $_.IPAddress -eq $ip } | Remove-NetIPAddress -Confirm:$false -ErrorAction SilentlyContinue",
|
||||||
|
" New-NetIPAddress -IPAddress $ip -PrefixLength 128 -InterfaceIndex $iface.ifIndex -SkipAsSource:$false -ErrorAction SilentlyContinue | Out-Null",
|
||||||
|
" }",
|
||||||
" Get-NetRoute -InterfaceIndex $iface.ifIndex -DestinationPrefix '::/0' -ErrorAction SilentlyContinue | Remove-NetRoute -Confirm:$false -ErrorAction SilentlyContinue",
|
" Get-NetRoute -InterfaceIndex $iface.ifIndex -DestinationPrefix '::/0' -ErrorAction SilentlyContinue | Remove-NetRoute -Confirm:$false -ErrorAction SilentlyContinue",
|
||||||
" New-NetRoute -DestinationPrefix '::/0' -InterfaceIndex $iface.ifIndex -NextHop '" + ipv6GatewayLinkLocal + "' -RouteMetric 100 -ErrorAction SilentlyContinue | Out-Null",
|
" New-NetRoute -DestinationPrefix '::/0' -InterfaceIndex $iface.ifIndex -NextHop '" + ipv6GatewayLinkLocal + "' -RouteMetric 100 -ErrorAction SilentlyContinue | Out-Null",
|
||||||
" Set-DnsClientServerAddress -InterfaceIndex $iface.ifIndex -ServerAddresses @('2001:4860:4860::8888','2606:4700:4700::1111') -ErrorAction SilentlyContinue",
|
" Set-DnsClientServerAddress -InterfaceIndex $iface.ifIndex -ServerAddresses @('2001:4860:4860::8888','2606:4700:4700::1111') -ErrorAction SilentlyContinue",
|
||||||
@@ -1765,13 +1788,14 @@ func shellQuoteWindows(value string) string {
|
|||||||
return `"` + strings.ReplaceAll(value, `"`, `\"`) + `"`
|
return `"` + strings.ReplaceAll(value, `"`, `\"`) + `"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func createSeedISO(seedPath, instanceID, hostname, password, mac, ipv6 string, image Image) error {
|
func createSeedISO(seedPath, instanceID, hostname, password, mac string, ipv6s []string, image Image) error {
|
||||||
guestSetup := kvmSSHSetupScript(password)
|
guestSetup := kvmSSHSetupScript(password)
|
||||||
if desktopSetup := kvmDesktopSetupScript(image); desktopSetup != "" {
|
if desktopSetup := kvmDesktopSetupScript(image); desktopSetup != "" {
|
||||||
guestSetup += "\n" + desktopSetup
|
guestSetup += "\n" + desktopSetup
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(ipv6) != "" {
|
ipv6s = normalizeKVMIPv6List(ipv6s)
|
||||||
guestSetup += "\n" + kvmIPv6SetupScript(ipv6)
|
if len(ipv6s) > 0 {
|
||||||
|
guestSetup += "\n" + kvmIPv6SetupScript(ipv6s)
|
||||||
}
|
}
|
||||||
setupScript := indentScript(guestSetup, 4)
|
setupScript := indentScript(guestSetup, 4)
|
||||||
userData := fmt.Sprintf(`#cloud-config
|
userData := fmt.Sprintf(`#cloud-config
|
||||||
@@ -1795,15 +1819,19 @@ runcmd:
|
|||||||
`, hostname, password, setupScript)
|
`, hostname, password, setupScript)
|
||||||
metaData := fmt.Sprintf("instance-id: %s\nlocal-hostname: %s\n", instanceID, hostname)
|
metaData := fmt.Sprintf("instance-id: %s\nlocal-hostname: %s\n", instanceID, hostname)
|
||||||
ipv6Block := ""
|
ipv6Block := ""
|
||||||
if strings.TrimSpace(ipv6) != "" {
|
if len(ipv6s) > 0 {
|
||||||
|
addressLines := make([]string, 0, len(ipv6s))
|
||||||
|
for _, ipv6 := range ipv6s {
|
||||||
|
addressLines = append(addressLines, fmt.Sprintf(" - %s/128", ipv6))
|
||||||
|
}
|
||||||
ipv6Block = fmt.Sprintf(`
|
ipv6Block = fmt.Sprintf(`
|
||||||
addresses:
|
addresses:
|
||||||
- %s/128
|
%s
|
||||||
routes:
|
routes:
|
||||||
- to: default
|
- to: default
|
||||||
via: %s
|
via: %s
|
||||||
on-link: true
|
on-link: true
|
||||||
metric: 100`, ipv6, ipv6GatewayLinkLocal)
|
metric: 100`, strings.Join(addressLines, "\n"), ipv6GatewayLinkLocal)
|
||||||
}
|
}
|
||||||
networkConfig := fmt.Sprintf(`version: 2
|
networkConfig := fmt.Sprintf(`version: 2
|
||||||
ethernets:
|
ethernets:
|
||||||
@@ -1834,6 +1862,42 @@ ethernets:
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func configIPv6AssignmentAddresses(assignments []config.IPv6Assignment) []string {
|
||||||
|
values := make([]string, 0, len(assignments))
|
||||||
|
for _, item := range assignments {
|
||||||
|
if strings.TrimSpace(item.Address) != "" {
|
||||||
|
values = append(values, strings.TrimSpace(item.Address))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return values
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeKVMIPv6List(values []string) []string {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
result := make([]string, 0, len(values))
|
||||||
|
for _, value := range values {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" || seen[value] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[value] = true
|
||||||
|
result = append(result, value)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func shellQuotedKVMIPv6List(values []string) string {
|
||||||
|
values = normalizeKVMIPv6List(values)
|
||||||
|
if len(values) == 0 {
|
||||||
|
return "''"
|
||||||
|
}
|
||||||
|
quoted := make([]string, 0, len(values))
|
||||||
|
for _, value := range values {
|
||||||
|
quoted = append(quoted, shellQuote(value))
|
||||||
|
}
|
||||||
|
return strings.Join(quoted, " ")
|
||||||
|
}
|
||||||
|
|
||||||
func indentScript(script string, spaces int) string {
|
func indentScript(script string, spaces int) string {
|
||||||
prefix := strings.Repeat(" ", spaces)
|
prefix := strings.Repeat(" ", spaces)
|
||||||
lines := strings.Split(strings.TrimRight(script, "\n"), "\n")
|
lines := strings.Split(strings.TrimRight(script, "\n"), "\n")
|
||||||
@@ -2575,7 +2639,7 @@ func (m *Manager) syncRunningNetworks() {
|
|||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
fmt.Printf("Warning: failed to sync KVM network for %s: %v\n", c.Name, err)
|
fmt.Printf("Warning: failed to sync KVM network for %s: %v\n", c.Name, err)
|
||||||
}
|
}
|
||||||
if c.IPv6 != "" {
|
if c.IPv6 != "" || len(c.IPv6Addresses) > 0 {
|
||||||
if err := m.applyIPv6Runtime(c); err != nil {
|
if err := m.applyIPv6Runtime(c); err != nil {
|
||||||
fmt.Printf("Warning: failed to sync KVM IPv6 for %s: %v\n", c.Name, err)
|
fmt.Printf("Warning: failed to sync KVM IPv6 for %s: %v\n", c.Name, err)
|
||||||
}
|
}
|
||||||
@@ -2589,7 +2653,7 @@ func (m *Manager) applyIPv6Guards() {
|
|||||||
if !c.IsKVM() || c.MACAddress == "" {
|
if !c.IsKVM() || c.MACAddress == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if c.IPv6 == "" {
|
if c.IPv6 == "" && len(c.IPv6Addresses) == 0 {
|
||||||
ensureKVMIPv6DenyRule("virbr0", c.MACAddress)
|
ensureKVMIPv6DenyRule("virbr0", c.MACAddress)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -2935,13 +2999,12 @@ func (m *Manager) AssignIPv6(id int) (*config.Container, error) {
|
|||||||
return nil, fmt.Errorf("container is not a KVM VM: %d", id)
|
return nil, fmt.Errorf("container is not a KVM VM: %d", id)
|
||||||
}
|
}
|
||||||
if c.IPv6 == "" {
|
if c.IPv6 == "" {
|
||||||
addr, prefixLen, iface, err := m.allocateIPv6ForContainer(id)
|
assignments, err := m.allocateIPv6AssignmentsForContainer(id, nil, 1, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
c.IPv6 = addr
|
c.IPv6Addresses = append(c.IPv6Addresses, assignments...)
|
||||||
c.IPv6PrefixLen = prefixLen
|
c.NormalizeNetworkAssignments()
|
||||||
c.IPv6Interface = iface
|
|
||||||
config.SaveConfig()
|
config.SaveConfig()
|
||||||
}
|
}
|
||||||
if err := m.applyIPv6Runtime(c); err != nil {
|
if err := m.applyIPv6Runtime(c); err != nil {
|
||||||
@@ -2951,9 +3014,10 @@ func (m *Manager) AssignIPv6(id int) (*config.Container, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) applyIPv6Runtime(c *config.Container) error {
|
func (m *Manager) applyIPv6Runtime(c *config.Container) error {
|
||||||
if c == nil || c.IPv6 == "" {
|
if c == nil || (c.IPv6 == "" && len(c.IPv6Addresses) == 0) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
c.NormalizeNetworkAssignments()
|
||||||
if err := m.applyIPv6HostRuntime(c); err != nil {
|
if err := m.applyIPv6HostRuntime(c); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -2964,7 +3028,13 @@ func (m *Manager) applyIPv6Runtime(c *config.Container) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ensureKVMIPv6NAT66(c.IPv6, c.IPv6Interface)
|
for _, assignment := range c.IPv6Addresses {
|
||||||
|
uplink := assignment.Interface
|
||||||
|
if uplink == "" {
|
||||||
|
uplink = c.IPv6Interface
|
||||||
|
}
|
||||||
|
ensureKVMIPv6NAT66(assignment.Address, uplink)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2980,9 +3050,10 @@ func shouldLogIPv6GuestWarning(id int) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) applyIPv6HostRuntime(c *config.Container) error {
|
func (m *Manager) applyIPv6HostRuntime(c *config.Container) error {
|
||||||
if c == nil || c.IPv6 == "" {
|
if c == nil || (c.IPv6 == "" && len(c.IPv6Addresses) == 0) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
c.NormalizeNetworkAssignments()
|
||||||
if c.IPv6Interface == "" {
|
if c.IPv6Interface == "" {
|
||||||
prefixes := lxc.DetectPublicIPv6Prefixes()
|
prefixes := lxc.DetectPublicIPv6Prefixes()
|
||||||
if len(prefixes) == 0 {
|
if len(prefixes) == 0 {
|
||||||
@@ -2990,6 +3061,14 @@ func (m *Manager) applyIPv6HostRuntime(c *config.Container) error {
|
|||||||
}
|
}
|
||||||
c.IPv6Interface = prefixes[0].Interface
|
c.IPv6Interface = prefixes[0].Interface
|
||||||
c.IPv6PrefixLen = prefixes[0].PrefixLen
|
c.IPv6PrefixLen = prefixes[0].PrefixLen
|
||||||
|
for i := range c.IPv6Addresses {
|
||||||
|
if c.IPv6Addresses[i].Interface == "" {
|
||||||
|
c.IPv6Addresses[i].Interface = c.IPv6Interface
|
||||||
|
}
|
||||||
|
if c.IPv6Addresses[i].PrefixLen == 0 {
|
||||||
|
c.IPv6Addresses[i].PrefixLen = c.IPv6PrefixLen
|
||||||
|
}
|
||||||
|
}
|
||||||
config.SaveConfig()
|
config.SaveConfig()
|
||||||
}
|
}
|
||||||
runQuiet("sysctl", "-w", "net.ipv6.conf.all.forwarding=1")
|
runQuiet("sysctl", "-w", "net.ipv6.conf.all.forwarding=1")
|
||||||
@@ -3001,14 +3080,20 @@ func (m *Manager) applyIPv6HostRuntime(c *config.Container) error {
|
|||||||
runQuiet("sysctl", "-w", "net.ipv6.conf."+bridge+".proxy_ndp=1")
|
runQuiet("sysctl", "-w", "net.ipv6.conf."+bridge+".proxy_ndp=1")
|
||||||
runQuiet("ip", "link", "set", bridge, "up")
|
runQuiet("ip", "link", "set", bridge, "up")
|
||||||
runQuiet("ip", "-6", "addr", "replace", ipv6GatewayLinkLocal+"/64", "dev", bridge)
|
runQuiet("ip", "-6", "addr", "replace", ipv6GatewayLinkLocal+"/64", "dev", bridge)
|
||||||
if out, err := exec.Command("ip", "-6", "route", "replace", c.IPv6+"/128", "dev", bridge).CombinedOutput(); err != nil {
|
for _, assignment := range c.IPv6Addresses {
|
||||||
|
uplink := assignment.Interface
|
||||||
|
if uplink == "" {
|
||||||
|
uplink = c.IPv6Interface
|
||||||
|
}
|
||||||
|
if out, err := exec.Command("ip", "-6", "route", "replace", assignment.Address+"/128", "dev", bridge).CombinedOutput(); err != nil {
|
||||||
return fmt.Errorf("failed to add IPv6 VM route: %v, output: %s", err, string(out))
|
return fmt.Errorf("failed to add IPv6 VM route: %v, output: %s", err, string(out))
|
||||||
}
|
}
|
||||||
if out, err := exec.Command("ip", "-6", "neigh", "replace", "proxy", c.IPv6, "dev", c.IPv6Interface).CombinedOutput(); err != nil {
|
if out, err := exec.Command("ip", "-6", "neigh", "replace", "proxy", assignment.Address, "dev", uplink).CombinedOutput(); err != nil {
|
||||||
return fmt.Errorf("failed to add IPv6 proxy NDP: %v, output: %s", err, string(out))
|
return fmt.Errorf("failed to add IPv6 proxy NDP: %v, output: %s", err, string(out))
|
||||||
}
|
}
|
||||||
ensureKVMIPv6ForwardRules(c.IPv6, bridge)
|
ensureKVMIPv6ForwardRules(assignment.Address, bridge)
|
||||||
ensureKVMIPv6AntiSpoofRules(c.IPv6, bridge, c.MACAddress)
|
ensureKVMIPv6AntiSpoofRules(assignment.Address, bridge, c.MACAddress)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3074,16 +3159,23 @@ func removeKVMIPv6Runtime(c *config.Container) {
|
|||||||
}
|
}
|
||||||
bridge := "virbr0"
|
bridge := "virbr0"
|
||||||
removeKVMIPv6DenyRule(bridge, c.MACAddress)
|
removeKVMIPv6DenyRule(bridge, c.MACAddress)
|
||||||
if c.IPv6 == "" {
|
if c.IPv6 == "" && len(c.IPv6Addresses) == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
removeKVMIPv6NAT66(c.IPv6, c.IPv6Interface)
|
c.NormalizeNetworkAssignments()
|
||||||
removeKVMIPv6ForwardRules(c.IPv6, bridge)
|
for _, assignment := range c.IPv6Addresses {
|
||||||
removeKVMIPv6AntiSpoofRules(c.IPv6, bridge, c.MACAddress)
|
uplink := assignment.Interface
|
||||||
if c.IPv6Interface != "" {
|
if uplink == "" {
|
||||||
_ = exec.Command("ip", "-6", "neigh", "del", "proxy", c.IPv6, "dev", c.IPv6Interface).Run()
|
uplink = c.IPv6Interface
|
||||||
|
}
|
||||||
|
removeKVMIPv6NAT66(assignment.Address, uplink)
|
||||||
|
removeKVMIPv6ForwardRules(assignment.Address, bridge)
|
||||||
|
removeKVMIPv6AntiSpoofRules(assignment.Address, bridge, c.MACAddress)
|
||||||
|
if uplink != "" {
|
||||||
|
_ = exec.Command("ip", "-6", "neigh", "del", "proxy", assignment.Address, "dev", uplink).Run()
|
||||||
|
}
|
||||||
|
_ = exec.Command("ip", "-6", "route", "del", assignment.Address+"/128", "dev", bridge).Run()
|
||||||
}
|
}
|
||||||
_ = exec.Command("ip", "-6", "route", "del", c.IPv6+"/128", "dev", bridge).Run()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func removeKVMIPv6ForwardRules(ipv6 string, bridge string) {
|
func removeKVMIPv6ForwardRules(ipv6 string, bridge string) {
|
||||||
@@ -3147,13 +3239,14 @@ func deleteIP6Rule(rule []string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) applyGuestIPv6(c *config.Container) error {
|
func (m *Manager) applyGuestIPv6(c *config.Container) error {
|
||||||
if c == nil || c.IPv6 == "" {
|
if c == nil || (c.IPv6 == "" && len(c.IPv6Addresses) == 0) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
c.NormalizeNetworkAssignments()
|
||||||
if IsWindowsImage(c.Template) {
|
if IsWindowsImage(c.Template) {
|
||||||
return m.applyWindowsGuestIPv6(c)
|
return m.applyWindowsGuestIPv6(c)
|
||||||
}
|
}
|
||||||
script := kvmIPv6SetupScript(c.IPv6)
|
script := kvmIPv6SetupScript(c.IPv6AddressStrings())
|
||||||
if err := qemuGuestPing(c.VirshName()); err != nil {
|
if err := qemuGuestPing(c.VirshName()); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -3167,14 +3260,15 @@ func (m *Manager) applyWindowsGuestIPv6(c *config.Container) error {
|
|||||||
if err := qemuGuestPing(c.VirshName()); err != nil {
|
if err := qemuGuestPing(c.VirshName()); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
script := windowsIPv6PowerShell(c.IPv6)
|
script := windowsIPv6PowerShell(c.IPv6AddressStrings())
|
||||||
return qemuGuestExecCommand(c.VirshName(), "powershell.exe", []string{"-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script}, 60*time.Second)
|
return qemuGuestExecCommand(c.VirshName(), "powershell.exe", []string{"-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script}, 60*time.Second)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) applyGuestIPv6Runtime(c *config.Container) error {
|
func (m *Manager) applyGuestIPv6Runtime(c *config.Container) error {
|
||||||
if c == nil || c.IPv6 == "" {
|
if c == nil || (c.IPv6 == "" && len(c.IPv6Addresses) == 0) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
c.NormalizeNetworkAssignments()
|
||||||
qgaErr := m.applyGuestIPv6(c)
|
qgaErr := m.applyGuestIPv6(c)
|
||||||
if qgaErr == nil {
|
if qgaErr == nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -3187,7 +3281,7 @@ func (m *Manager) applyGuestIPv6Runtime(c *config.Container) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) applyGuestIPv6OverSSH(c *config.Container) error {
|
func (m *Manager) applyGuestIPv6OverSSH(c *config.Container) error {
|
||||||
if c == nil || c.IPv6 == "" {
|
if c == nil || (c.IPv6 == "" && len(c.IPv6Addresses) == 0) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if IsWindowsImage(c.Template) {
|
if IsWindowsImage(c.Template) {
|
||||||
@@ -3206,12 +3300,13 @@ func (m *Manager) applyGuestIPv6OverSSH(c *config.Container) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer client.Close()
|
defer client.Close()
|
||||||
return runKVMSSHScript(client, kvmIPv6SetupScript(c.IPv6), "KVM IPv6", 60*time.Second)
|
return runKVMSSHScript(client, kvmIPv6SetupScript(c.IPv6AddressStrings()), "KVM IPv6", 60*time.Second)
|
||||||
}
|
}
|
||||||
|
|
||||||
func kvmIPv6SetupScript(ipv6 string) string {
|
func kvmIPv6SetupScript(ipv6s []string) string {
|
||||||
|
ipv6s = normalizeKVMIPv6List(ipv6s)
|
||||||
return `set -eu
|
return `set -eu
|
||||||
IPV6_ADDR=` + shellQuote(ipv6) + `
|
IPV6_ADDRS="` + strings.Join(ipv6s, " ") + `"
|
||||||
IPV6_GW=` + shellQuote(ipv6GatewayLinkLocal) + `
|
IPV6_GW=` + shellQuote(ipv6GatewayLinkLocal) + `
|
||||||
IFACE="$(ip -o -4 route show default 2>/dev/null | awk '{print $5; exit}')"
|
IFACE="$(ip -o -4 route show default 2>/dev/null | awk '{print $5; exit}')"
|
||||||
if [ -z "$IFACE" ]; then
|
if [ -z "$IFACE" ]; then
|
||||||
@@ -3224,13 +3319,15 @@ fi
|
|||||||
sysctl -w net.ipv6.conf.all.disable_ipv6=0 >/dev/null 2>&1 || true
|
sysctl -w net.ipv6.conf.all.disable_ipv6=0 >/dev/null 2>&1 || true
|
||||||
sysctl -w net.ipv6.conf.default.disable_ipv6=0 >/dev/null 2>&1 || true
|
sysctl -w net.ipv6.conf.default.disable_ipv6=0 >/dev/null 2>&1 || true
|
||||||
sysctl -w net.ipv6.conf."$IFACE".disable_ipv6=0 >/dev/null 2>&1 || true
|
sysctl -w net.ipv6.conf."$IFACE".disable_ipv6=0 >/dev/null 2>&1 || true
|
||||||
|
for IPV6_ADDR in $IPV6_ADDRS; do
|
||||||
ip -6 addr replace "$IPV6_ADDR/128" dev "$IFACE"
|
ip -6 addr replace "$IPV6_ADDR/128" dev "$IFACE"
|
||||||
|
done
|
||||||
ip -6 route replace default via "$IPV6_GW" dev "$IFACE" onlink metric 100
|
ip -6 route replace default via "$IPV6_GW" dev "$IFACE" onlink metric 100
|
||||||
mkdir -p /usr/local/sbin /etc/systemd/system /etc/network/if-up.d /etc/local.d
|
mkdir -p /usr/local/sbin /etc/systemd/system /etc/network/if-up.d /etc/local.d
|
||||||
cat > /usr/local/sbin/clicd-kvm-ipv6-init <<'EOF'
|
cat > /usr/local/sbin/clicd-kvm-ipv6-init <<'EOF'
|
||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
set -eu
|
set -eu
|
||||||
IPV6_ADDR=` + shellQuote(ipv6) + `
|
IPV6_ADDRS="` + strings.Join(ipv6s, " ") + `"
|
||||||
IPV6_GW=` + shellQuote(ipv6GatewayLinkLocal) + `
|
IPV6_GW=` + shellQuote(ipv6GatewayLinkLocal) + `
|
||||||
IFACE="$(ip -o -4 route show default 2>/dev/null | awk '{print $5; exit}')"
|
IFACE="$(ip -o -4 route show default 2>/dev/null | awk '{print $5; exit}')"
|
||||||
if [ -z "$IFACE" ]; then
|
if [ -z "$IFACE" ]; then
|
||||||
@@ -3240,7 +3337,9 @@ fi
|
|||||||
sysctl -w net.ipv6.conf.all.disable_ipv6=0 >/dev/null 2>&1 || true
|
sysctl -w net.ipv6.conf.all.disable_ipv6=0 >/dev/null 2>&1 || true
|
||||||
sysctl -w net.ipv6.conf.default.disable_ipv6=0 >/dev/null 2>&1 || true
|
sysctl -w net.ipv6.conf.default.disable_ipv6=0 >/dev/null 2>&1 || true
|
||||||
sysctl -w net.ipv6.conf."$IFACE".disable_ipv6=0 >/dev/null 2>&1 || true
|
sysctl -w net.ipv6.conf."$IFACE".disable_ipv6=0 >/dev/null 2>&1 || true
|
||||||
|
for IPV6_ADDR in $IPV6_ADDRS; do
|
||||||
ip -6 addr replace "$IPV6_ADDR/128" dev "$IFACE"
|
ip -6 addr replace "$IPV6_ADDR/128" dev "$IFACE"
|
||||||
|
done
|
||||||
ip -6 route replace default via "$IPV6_GW" dev "$IFACE" onlink metric 100
|
ip -6 route replace default via "$IPV6_GW" dev "$IFACE" onlink metric 100
|
||||||
EOF
|
EOF
|
||||||
chmod +x /usr/local/sbin/clicd-kvm-ipv6-init
|
chmod +x /usr/local/sbin/clicd-kvm-ipv6-init
|
||||||
@@ -3279,15 +3378,44 @@ chmod +x /etc/network/if-up.d/clicd-kvm-ipv6
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) allocateIPv6ForContainer(id int) (string, int, string, error) {
|
func (m *Manager) allocateIPv6ForContainer(id int) (string, int, string, error) {
|
||||||
prefixes := lxc.DetectPublicIPv6Prefixes()
|
assignments, err := m.allocateIPv6AssignmentsForContainer(id, nil, 1, true)
|
||||||
if len(prefixes) == 0 {
|
|
||||||
return "", 0, "", fmt.Errorf("public IPv6 allocation is unavailable: no usable public IPv6 prefix found")
|
|
||||||
}
|
|
||||||
prefixInfo := prefixes[0]
|
|
||||||
prefix, err := netip.ParsePrefix(prefixInfo.Prefix)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", 0, "", err
|
return "", 0, "", err
|
||||||
}
|
}
|
||||||
|
if len(assignments) == 0 {
|
||||||
|
return "", 0, "", fmt.Errorf("no free IPv6 address")
|
||||||
|
}
|
||||||
|
return assignments[0].Address, assignments[0].PrefixLen, assignments[0].Interface, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) allocateIPv6AssignmentsForContainer(id int, requested []string, count int, auto bool) ([]config.IPv6Assignment, error) {
|
||||||
|
if count <= 0 {
|
||||||
|
count = 1
|
||||||
|
}
|
||||||
|
if len(requested) > count {
|
||||||
|
count = len(requested)
|
||||||
|
}
|
||||||
|
prefixes := lxc.DetectPublicIPv6Prefixes()
|
||||||
|
if len(prefixes) == 0 {
|
||||||
|
return nil, fmt.Errorf("public IPv6 allocation is unavailable: no usable public IPv6 prefix found")
|
||||||
|
}
|
||||||
|
parsedPrefixes := make([]struct {
|
||||||
|
info lxc.IPv6PrefixInfo
|
||||||
|
prefix netip.Prefix
|
||||||
|
}, 0, len(prefixes))
|
||||||
|
for _, prefixInfo := range prefixes {
|
||||||
|
prefix, err := netip.ParsePrefix(prefixInfo.Prefix)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parsedPrefixes = append(parsedPrefixes, struct {
|
||||||
|
info lxc.IPv6PrefixInfo
|
||||||
|
prefix netip.Prefix
|
||||||
|
}{info: prefixInfo, prefix: prefix})
|
||||||
|
}
|
||||||
|
if len(parsedPrefixes) == 0 {
|
||||||
|
return nil, fmt.Errorf("public IPv6 allocation is unavailable: no valid IPv6 prefix found")
|
||||||
|
}
|
||||||
|
|
||||||
used := map[string]bool{}
|
used := map[string]bool{}
|
||||||
hostAddrs := map[string]bool{}
|
hostAddrs := map[string]bool{}
|
||||||
@@ -3295,21 +3423,69 @@ func (m *Manager) allocateIPv6ForContainer(id int) (string, int, string, error)
|
|||||||
hostAddrs[p.Address] = true
|
hostAddrs[p.Address] = true
|
||||||
}
|
}
|
||||||
for _, c := range config.AppConfig.Containers {
|
for _, c := range config.AppConfig.Containers {
|
||||||
|
if c.ID == id {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if c.IPv6 != "" {
|
if c.IPv6 != "" {
|
||||||
used[c.IPv6] = true
|
used[c.IPv6] = true
|
||||||
}
|
}
|
||||||
|
for _, ip := range c.IPv6Addresses {
|
||||||
|
if ip.Address != "" {
|
||||||
|
used[ip.Address] = true
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result := make([]config.IPv6Assignment, 0, count)
|
||||||
|
selected := map[string]bool{}
|
||||||
|
for _, raw := range requested {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" || selected[raw] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
addr, err := netip.ParseAddr(raw)
|
||||||
|
if err != nil || !addr.Is6() {
|
||||||
|
return nil, fmt.Errorf("requested IPv6 %s is not valid", raw)
|
||||||
|
}
|
||||||
|
matchIndex := -1
|
||||||
|
for i, item := range parsedPrefixes {
|
||||||
|
if item.prefix.Contains(addr) {
|
||||||
|
matchIndex = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if matchIndex < 0 {
|
||||||
|
return nil, fmt.Errorf("requested IPv6 %s is not in the configured IPv6 prefixes", raw)
|
||||||
|
}
|
||||||
|
if hostAddrs[raw] {
|
||||||
|
return nil, fmt.Errorf("requested IPv6 %s is used by host", raw)
|
||||||
|
}
|
||||||
|
if used[raw] {
|
||||||
|
return nil, fmt.Errorf("requested IPv6 %s is already assigned", raw)
|
||||||
|
}
|
||||||
|
selected[raw] = true
|
||||||
|
used[raw] = true
|
||||||
|
result = append(result, config.IPv6Assignment{Address: raw, PrefixLen: parsedPrefixes[matchIndex].prefix.Bits(), Interface: parsedPrefixes[matchIndex].info.Interface})
|
||||||
|
}
|
||||||
|
if len(result) >= count || !auto {
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
for _, item := range parsedPrefixes {
|
||||||
for offset := uint64(0x2000 + id); offset < 0x100000; offset++ {
|
for offset := uint64(0x2000 + id); offset < 0x100000; offset++ {
|
||||||
addr, err := ipv6Add(prefix.Masked().Addr(), offset)
|
addr, err := ipv6Add(item.prefix.Masked().Addr(), offset)
|
||||||
if err != nil || !prefix.Contains(addr) {
|
if err != nil || !item.prefix.Contains(addr) {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
candidate := addr.String()
|
candidate := addr.String()
|
||||||
if !used[candidate] && !hostAddrs[candidate] {
|
if !used[candidate] && !hostAddrs[candidate] {
|
||||||
return candidate, prefix.Bits(), prefixInfo.Interface, nil
|
used[candidate] = true
|
||||||
|
result = append(result, config.IPv6Assignment{Address: candidate, PrefixLen: item.prefix.Bits(), Interface: item.info.Interface})
|
||||||
|
if len(result) >= count {
|
||||||
|
return result, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return "", 0, "", fmt.Errorf("no free IPv6 address in %s", prefix.String())
|
}
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("no free IPv6 address in configured prefixes")
|
||||||
}
|
}
|
||||||
|
|
||||||
func ipv6Add(base netip.Addr, offset uint64) (netip.Addr, error) {
|
func ipv6Add(base netip.Addr, offset uint64) (netip.Addr, error) {
|
||||||
|
|||||||
+1100
-31
File diff suppressed because it is too large
Load Diff
+162
-42
@@ -233,19 +233,32 @@ type ContainerConfig struct {
|
|||||||
IOSpeedMBps int `json:"io_speed_mbps"`
|
IOSpeedMBps int `json:"io_speed_mbps"`
|
||||||
ExtraPorts []int `json:"extra_ports"`
|
ExtraPorts []int `json:"extra_ports"`
|
||||||
PortMappingCount int `json:"port_mapping_count"`
|
PortMappingCount int `json:"port_mapping_count"`
|
||||||
|
AssignNAT *bool `json:"assign_nat,omitempty"`
|
||||||
SnapshotLimit int `json:"snapshot_limit"`
|
SnapshotLimit int `json:"snapshot_limit"`
|
||||||
|
AssignIPv4 bool `json:"assign_ipv4"`
|
||||||
|
IPv4Count int `json:"ipv4_count,omitempty"`
|
||||||
|
PublicIPv4s []string `json:"public_ipv4s,omitempty"`
|
||||||
AssignIPv6 bool `json:"assign_ipv6"`
|
AssignIPv6 bool `json:"assign_ipv6"`
|
||||||
|
IPv6Count int `json:"ipv6_count,omitempty"`
|
||||||
|
IPv6Addresses []string `json:"ipv6_addresses,omitempty"`
|
||||||
ExpiresAt string `json:"expires_at"`
|
ExpiresAt string `json:"expires_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (cfg ContainerConfig) WantsNAT() bool {
|
||||||
|
return cfg.AssignNAT == nil || *cfg.AssignNAT
|
||||||
|
}
|
||||||
|
|
||||||
// CreateContainer creates a new LXC container. Uses ct-{id} as LXC name internally.
|
// CreateContainer creates a new LXC container. Uses ct-{id} as LXC name internally.
|
||||||
func (m *Manager) CreateContainer(cfg ContainerConfig) error {
|
func (m *Manager) CreateContainer(cfg ContainerConfig) error {
|
||||||
tmpl := FindTemplate(cfg.TemplateID)
|
tmpl := FindTemplate(cfg.TemplateID)
|
||||||
if tmpl == nil {
|
if tmpl == nil {
|
||||||
return fmt.Errorf("template not found: %s", cfg.TemplateID)
|
return fmt.Errorf("template not found: %s", cfg.TemplateID)
|
||||||
}
|
}
|
||||||
if cfg.PortMappingCount < 2 {
|
if cfg.WantsNAT() && cfg.PortMappingCount < 2 {
|
||||||
cfg.PortMappingCount = 2
|
cfg.PortMappingCount = 2
|
||||||
|
} else if !cfg.WantsNAT() {
|
||||||
|
cfg.PortMappingCount = 0
|
||||||
|
cfg.ExtraPorts = nil
|
||||||
}
|
}
|
||||||
if cfg.SnapshotLimit <= 0 {
|
if cfg.SnapshotLimit <= 0 {
|
||||||
cfg.SnapshotLimit = config.DefaultSnapshotLimit
|
cfg.SnapshotLimit = config.DefaultSnapshotLimit
|
||||||
@@ -296,30 +309,42 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
ipv6 := ""
|
publicIPv4s, err := AllocatePublicIPv4Assignments(id, cfg.PublicIPv4s, cfg.IPv4Count, cfg.AssignIPv4)
|
||||||
ipv6PrefixLen := 0
|
|
||||||
ipv6Interface := ""
|
|
||||||
if cfg.AssignIPv6 {
|
|
||||||
assigned, prefixLen, iface, err := m.allocateIPv6ForContainer(id)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = m.cleanupContainerStorage(lxcName)
|
_ = m.cleanupContainerStorage(lxcName)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
ipv6 = assigned
|
|
||||||
ipv6PrefixLen = prefixLen
|
ipv6Assignments := []config.IPv6Assignment{}
|
||||||
ipv6Interface = iface
|
if cfg.AssignIPv6 || len(cfg.IPv6Addresses) > 0 {
|
||||||
if err := m.applyIPv6Config(lxcName, ipv6); err != nil {
|
assigned, err := m.allocateIPv6AssignmentsForContainer(id, cfg.IPv6Addresses, cfg.IPv6Count, true)
|
||||||
|
if err != nil {
|
||||||
|
_ = m.cleanupContainerStorage(lxcName)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ipv6Assignments = assigned
|
||||||
|
if err := m.applyIPv6Config(lxcName, ipv6AssignmentAddresses(ipv6Assignments)...); err != nil {
|
||||||
_ = m.cleanupContainerStorage(lxcName)
|
_ = m.cleanupContainerStorage(lxcName)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sshPort := config.AllocateSSHPort()
|
|
||||||
sshPassword := generateRandomString(16)
|
sshPassword := generateRandomString(16)
|
||||||
|
|
||||||
|
sshPort := 0
|
||||||
|
portMappings := []config.PortMapping{}
|
||||||
|
if cfg.WantsNAT() {
|
||||||
|
sshPort = config.AllocateSSHPort()
|
||||||
|
|
||||||
// Setup default port mappings (SSH only)
|
// Setup default port mappings (SSH only)
|
||||||
portMappings := SetupDefaultPortMappings(sshPort)
|
portMappings = SetupDefaultPortMappings(sshPort)
|
||||||
tempC := &config.Container{PortMappings: portMappings}
|
defaultHostIP := defaultPortMappingHostIP(publicIPv4s)
|
||||||
|
if defaultHostIP != "" {
|
||||||
|
for i := range portMappings {
|
||||||
|
portMappings[i].HostIP = defaultHostIP
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tempC := &config.Container{ID: id, PublicIPv4s: publicIPv4s, PortMappings: portMappings}
|
||||||
|
|
||||||
extraPorts := cfg.ExtraPorts
|
extraPorts := cfg.ExtraPorts
|
||||||
if len(extraPorts) == 0 && cfg.PortMappingCount > 1 {
|
if len(extraPorts) == 0 && cfg.PortMappingCount > 1 {
|
||||||
@@ -332,6 +357,7 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
|
|||||||
pm, err := normalizePortMapping(tempC, -1, config.PortMapping{
|
pm, err := normalizePortMapping(tempC, -1, config.PortMapping{
|
||||||
ContainerPort: containerPort,
|
ContainerPort: containerPort,
|
||||||
HostPort: containerPort,
|
HostPort: containerPort,
|
||||||
|
HostIP: defaultHostIP,
|
||||||
Protocol: "tcp",
|
Protocol: "tcp",
|
||||||
Description: fmt.Sprintf("Port-%d", containerPort),
|
Description: fmt.Sprintf("Port-%d", containerPort),
|
||||||
})
|
})
|
||||||
@@ -341,6 +367,7 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
|
|||||||
tempC.PortMappings = append(tempC.PortMappings, pm)
|
tempC.PortMappings = append(tempC.PortMappings, pm)
|
||||||
portMappings = tempC.PortMappings
|
portMappings = tempC.PortMappings
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
now := time.Now().Format("2006-01-02 15:04:05")
|
now := time.Now().Format("2006-01-02 15:04:05")
|
||||||
// Determine traffic mode
|
// Determine traffic mode
|
||||||
@@ -368,9 +395,8 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
|
|||||||
IOSpeedMBps: cfg.IOSpeedMBps,
|
IOSpeedMBps: cfg.IOSpeedMBps,
|
||||||
Status: "stopped",
|
Status: "stopped",
|
||||||
IP: "",
|
IP: "",
|
||||||
IPv6: ipv6,
|
PublicIPv4s: publicIPv4s,
|
||||||
IPv6PrefixLen: ipv6PrefixLen,
|
IPv6Addresses: ipv6Assignments,
|
||||||
IPv6Interface: ipv6Interface,
|
|
||||||
VNCPort: 0,
|
VNCPort: 0,
|
||||||
SSHPort: sshPort,
|
SSHPort: sshPort,
|
||||||
SSHPassword: sshPassword,
|
SSHPassword: sshPassword,
|
||||||
@@ -380,13 +406,14 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
|
|||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
ExpiresAt: cfg.ExpiresAt,
|
ExpiresAt: cfg.ExpiresAt,
|
||||||
}
|
}
|
||||||
|
container.NormalizeNetworkAssignments()
|
||||||
config.AddContainer(container)
|
config.AddContainer(container)
|
||||||
|
|
||||||
// Pre-configure network and SSH in the rootfs before first boot.
|
// Pre-configure network and SSH in the rootfs before first boot.
|
||||||
rootfsPath := filepath.Join(m.LxcPath, lxcName, "rootfs")
|
rootfsPath := filepath.Join(m.LxcPath, lxcName, "rootfs")
|
||||||
m.preconfigureNetwork(rootfsPath, cfg.TemplateID)
|
m.preconfigureNetwork(rootfsPath, cfg.TemplateID)
|
||||||
if ipv6 != "" {
|
if len(ipv6Assignments) > 0 {
|
||||||
if err := installContainerIPv6Init(rootfsPath, ipv6); err != nil {
|
if err := installContainerIPv6Init(rootfsPath, ipv6AssignmentAddresses(ipv6Assignments)...); err != nil {
|
||||||
fmt.Printf("Warning: failed to install IPv6 init in %s: %v\n", lxcName, err)
|
fmt.Printf("Warning: failed to install IPv6 init in %s: %v\n", lxcName, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -525,7 +552,7 @@ func (m *Manager) applyResourceLimits(lxcName string, cfg ContainerConfig) error
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
apparmorProfile, err := findAppArmorProfile()
|
apparmorProfile, err := appArmorProfileForTemplate(cfg.TemplateID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -940,6 +967,26 @@ func findAppArmorProfile() (string, error) {
|
|||||||
return "", errors.New("required LXC AppArmor profile not loaded")
|
return "", errors.New("required LXC AppArmor profile not loaded")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func appArmorProfileForTemplate(templateID string) (string, error) {
|
||||||
|
if systemdTemplateNeedsUnconfinedAppArmor(templateID) {
|
||||||
|
return "unconfined", nil
|
||||||
|
}
|
||||||
|
return findAppArmorProfile()
|
||||||
|
}
|
||||||
|
|
||||||
|
func systemdTemplateNeedsUnconfinedAppArmor(templateID string) bool {
|
||||||
|
id := strings.ToLower(strings.TrimSpace(templateID))
|
||||||
|
if id == "" || strings.Contains(id, "alpine") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, token := range []string{"ubuntu", "debian", "centos", "fedora", "rocky", "rockylinux", "archlinux"} {
|
||||||
|
if strings.Contains(id, token) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func unprivilegedIDMap() (int, int, error) {
|
func unprivilegedIDMap() (int, int, error) {
|
||||||
if err := ensureSubIDRange("/etc/subuid", "root", 100000, 65536); err != nil {
|
if err := ensureSubIDRange("/etc/subuid", "root", 100000, 65536); err != nil {
|
||||||
return 0, 0, err
|
return 0, 0, err
|
||||||
@@ -1197,27 +1244,31 @@ func (m *Manager) StartContainer(id int) error {
|
|||||||
NetworkBWMbps: c.NetworkBWMbps,
|
NetworkBWMbps: c.NetworkBWMbps,
|
||||||
MonthlyTrafficGB: c.MonthlyTrafficGB,
|
MonthlyTrafficGB: c.MonthlyTrafficGB,
|
||||||
IOSpeedMBps: c.IOSpeedMBps,
|
IOSpeedMBps: c.IOSpeedMBps,
|
||||||
AssignIPv6: c.IPv6 != "",
|
AssignIPv6: c.IPv6 != "" || len(c.IPv6Addresses) > 0,
|
||||||
ExpiresAt: c.ExpiresAt,
|
ExpiresAt: c.ExpiresAt,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if c.IPv6 != "" {
|
if c.IPv6 != "" || len(c.IPv6Addresses) > 0 {
|
||||||
if err := m.applyIPv6Config(lxcName, c.IPv6); err != nil {
|
c.NormalizeNetworkAssignments()
|
||||||
|
if err := m.applyIPv6Config(lxcName, c.IPv6AddressStrings()...); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := m.ApplyIPv6(id); err != nil {
|
if err := m.ApplyIPv6(id); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
EnsureAssignedPublicIPv4s(c.PublicIPv4s)
|
||||||
|
|
||||||
logFile := filepath.Join(os.TempDir(), "clicd-"+lxcName+"-start.log")
|
logFile, consoleLog, output, err := m.startLXCContainerDaemon(lxcName)
|
||||||
os.Remove(logFile)
|
|
||||||
cmd := exec.Command("lxc-start", "-n", lxcName, "-d", "--logfile", logFile, "--logpriority", "DEBUG")
|
|
||||||
output, err := cmd.CombinedOutput()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to start container: %v, output: %s, lxc log: %s", err, string(output), tailFile(logFile, 80))
|
config.UpdateContainerStatus(id, "stopped")
|
||||||
|
return fmt.Errorf("failed to start container: %v, output: %s, lxc log: %s, console: %s", err, string(output), tailFile(logFile, 80), tailFile(consoleLog, 80))
|
||||||
|
}
|
||||||
|
if err := m.waitForLXCStartup(lxcName, logFile, consoleLog); err != nil {
|
||||||
|
config.UpdateContainerStatus(id, "stopped")
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
config.UpdateContainerStatus(id, "running")
|
config.UpdateContainerStatus(id, "running")
|
||||||
@@ -1262,7 +1313,7 @@ func (m *Manager) StartContainer(id int) error {
|
|||||||
if err := m.ApplyPortMappings(id); err != nil {
|
if err := m.ApplyPortMappings(id); err != nil {
|
||||||
fmt.Printf("Warning: failed to apply port mappings: %v\n", err)
|
fmt.Printf("Warning: failed to apply port mappings: %v\n", err)
|
||||||
}
|
}
|
||||||
if c.IPv6 != "" {
|
if c.IPv6 != "" || len(c.IPv6Addresses) > 0 {
|
||||||
if err := m.ApplyIPv6(id); err != nil {
|
if err := m.ApplyIPv6(id); err != nil {
|
||||||
fmt.Printf("Warning: failed to apply IPv6 routing for %s: %v\n", lxcName, err)
|
fmt.Printf("Warning: failed to apply IPv6 routing for %s: %v\n", lxcName, err)
|
||||||
}
|
}
|
||||||
@@ -1272,6 +1323,41 @@ func (m *Manager) StartContainer(id int) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *Manager) startLXCContainerDaemon(lxcName string) (string, string, []byte, error) {
|
||||||
|
logFile := filepath.Join(os.TempDir(), "clicd-"+lxcName+"-start.log")
|
||||||
|
consoleLog := filepath.Join(os.TempDir(), "clicd-"+lxcName+"-console.log")
|
||||||
|
os.Remove(logFile)
|
||||||
|
os.Remove(consoleLog)
|
||||||
|
cmd := exec.Command("lxc-start", "-n", lxcName, "-d", "--logfile", logFile, "--logpriority", "DEBUG", "--console-log", consoleLog)
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
return logFile, consoleLog, output, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) waitForLXCStartup(lxcName, logFile, consoleLog string) error {
|
||||||
|
runningChecks := 0
|
||||||
|
lastStatus := "unknown"
|
||||||
|
for retry := 0; retry < 10; retry++ {
|
||||||
|
time.Sleep(1 * time.Second)
|
||||||
|
status, err := m.GetContainerStatus(lxcName)
|
||||||
|
if err != nil {
|
||||||
|
lastStatus = "unknown"
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lastStatus = status
|
||||||
|
if status == "running" {
|
||||||
|
runningChecks++
|
||||||
|
if runningChecks >= 3 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if runningChecks > 0 || retry >= 1 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Errorf("container exited immediately after start (status: %s), lxc log: %s, console: %s", lastStatus, tailFile(logFile, 80), tailFile(consoleLog, 80))
|
||||||
|
}
|
||||||
|
|
||||||
// applyBandwidthLimit applies tc-based bandwidth limit on container's veth interface
|
// applyBandwidthLimit applies tc-based bandwidth limit on container's veth interface
|
||||||
// ApplyContainerLimits re-applies resource limits (CPU, RAM, IO, BW) to a running container.
|
// ApplyContainerLimits re-applies resource limits (CPU, RAM, IO, BW) to a running container.
|
||||||
func (m *Manager) ApplyContainerLimits(c *config.Container) error {
|
func (m *Manager) ApplyContainerLimits(c *config.Container) error {
|
||||||
@@ -1553,8 +1639,17 @@ func (m *Manager) DestroyContainer(id int) error {
|
|||||||
return fmt.Errorf("container not found: %d", id)
|
return fmt.Errorf("container not found: %d", id)
|
||||||
}
|
}
|
||||||
lxcName := c.LxcName()
|
lxcName := c.LxcName()
|
||||||
if c.IPv6 != "" && c.IPv6Interface != "" {
|
if c.IPv6 != "" || len(c.IPv6Addresses) > 0 {
|
||||||
removeHostIPv6Routing(c.IPv6, c.IPv6Interface)
|
c.NormalizeNetworkAssignments()
|
||||||
|
for _, assignment := range c.IPv6Addresses {
|
||||||
|
uplink := assignment.Interface
|
||||||
|
if uplink == "" {
|
||||||
|
uplink = c.IPv6Interface
|
||||||
|
}
|
||||||
|
if uplink != "" {
|
||||||
|
removeHostIPv6Routing(assignment.Address, uplink)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := m.StopContainer(id); err != nil {
|
if err := m.StopContainer(id); err != nil {
|
||||||
@@ -1799,6 +1894,11 @@ install_sshd() {
|
|||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ensure_sshd_runtime_dir() {
|
||||||
|
mkdir -p /run/sshd /var/run/sshd
|
||||||
|
chmod 0755 /run/sshd /var/run/sshd 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
set_sshd_option() {
|
set_sshd_option() {
|
||||||
key="$1"
|
key="$1"
|
||||||
value="$2"
|
value="$2"
|
||||||
@@ -1825,7 +1925,8 @@ set_sshd_option() {
|
|||||||
|
|
||||||
install_sshd || exit 30
|
install_sshd || exit 30
|
||||||
|
|
||||||
mkdir -p /run/sshd /var/run/sshd /etc/ssh /etc/ssh/sshd_config.d
|
mkdir -p /etc/ssh /etc/ssh/sshd_config.d
|
||||||
|
ensure_sshd_runtime_dir
|
||||||
ssh-keygen -A >/dev/null 2>&1 || true
|
ssh-keygen -A >/dev/null 2>&1 || true
|
||||||
|
|
||||||
cat >/etc/ssh/sshd_config.d/99-clicd.conf <<'EOF'
|
cat >/etc/ssh/sshd_config.d/99-clicd.conf <<'EOF'
|
||||||
@@ -1858,6 +1959,7 @@ if command -v chkconfig >/dev/null 2>&1; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
SSHD_BIN="$(sshd_path)" || exit 32
|
SSHD_BIN="$(sshd_path)" || exit 32
|
||||||
|
ensure_sshd_runtime_dir
|
||||||
"$SSHD_BIN" -t -f /etc/ssh/sshd_config >/tmp/clicd-sshd-test.log 2>&1 || {
|
"$SSHD_BIN" -t -f /etc/ssh/sshd_config >/tmp/clicd-sshd-test.log 2>&1 || {
|
||||||
cat /tmp/clicd-sshd-test.log
|
cat /tmp/clicd-sshd-test.log
|
||||||
exit 32
|
exit 32
|
||||||
@@ -1872,6 +1974,7 @@ if command -v systemctl >/dev/null 2>&1; then
|
|||||||
systemctl stop ssh.socket 2>/dev/null || true
|
systemctl stop ssh.socket 2>/dev/null || true
|
||||||
systemctl disable ssh.socket 2>/dev/null || true
|
systemctl disable ssh.socket 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
|
ensure_sshd_runtime_dir
|
||||||
if command -v systemctl >/dev/null 2>&1 && [ -d /run/systemd/system ]; then
|
if command -v systemctl >/dev/null 2>&1 && [ -d /run/systemd/system ]; then
|
||||||
systemctl restart ssh >/dev/null 2>&1 || systemctl restart sshd >/dev/null 2>&1 || true
|
systemctl restart ssh >/dev/null 2>&1 || systemctl restart sshd >/dev/null 2>&1 || true
|
||||||
fi
|
fi
|
||||||
@@ -1882,9 +1985,22 @@ service ssh restart >/dev/null 2>&1 ||
|
|||||||
/etc/init.d/sshd restart >/dev/null 2>&1 ||
|
/etc/init.d/sshd restart >/dev/null 2>&1 ||
|
||||||
true
|
true
|
||||||
|
|
||||||
|
ensure_sshd_runtime_dir
|
||||||
|
|
||||||
|
for i in 1 2 3 4 5; do
|
||||||
|
if (ss -ltn 2>/dev/null || netstat -tln 2>/dev/null) | grep -Eq '(^|[[:space:]])[^[:space:]]*:22[[:space:]]'; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if pgrep -x sshd >/dev/null 2>&1; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
if ! (ss -ltn 2>/dev/null || netstat -tln 2>/dev/null) | grep -Eq '(^|[[:space:]])[^[:space:]]*:22[[:space:]]'; then
|
if ! (ss -ltn 2>/dev/null || netstat -tln 2>/dev/null) | grep -Eq '(^|[[:space:]])[^[:space:]]*:22[[:space:]]'; then
|
||||||
pkill -x sshd >/dev/null 2>&1 || killall sshd >/dev/null 2>&1 || true
|
pkill -x sshd >/dev/null 2>&1 || killall sshd >/dev/null 2>&1 || true
|
||||||
rm -f /run/sshd.pid /var/run/sshd.pid
|
rm -f /run/sshd.pid /var/run/sshd.pid
|
||||||
|
ensure_sshd_runtime_dir
|
||||||
"$SSHD_BIN" -f /etc/ssh/sshd_config >/dev/null 2>&1 || exit 32
|
"$SSHD_BIN" -f /etc/ssh/sshd_config >/dev/null 2>&1 || exit 32
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -2425,14 +2541,15 @@ func (m *Manager) ReinstallContainer(id int, templateID string) error {
|
|||||||
NetworkBWMbps: c.NetworkBWMbps,
|
NetworkBWMbps: c.NetworkBWMbps,
|
||||||
MonthlyTrafficGB: c.MonthlyTrafficGB,
|
MonthlyTrafficGB: c.MonthlyTrafficGB,
|
||||||
IOSpeedMBps: c.IOSpeedMBps,
|
IOSpeedMBps: c.IOSpeedMBps,
|
||||||
AssignIPv6: c.IPv6 != "",
|
AssignIPv6: c.IPv6 != "" || len(c.IPv6Addresses) > 0,
|
||||||
ExpiresAt: c.ExpiresAt,
|
ExpiresAt: c.ExpiresAt,
|
||||||
}
|
}
|
||||||
if err := m.applyResourceLimits(lxcName, cfg); err != nil {
|
if err := m.applyResourceLimits(lxcName, cfg); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if c.IPv6 != "" {
|
if c.IPv6 != "" || len(c.IPv6Addresses) > 0 {
|
||||||
if err := m.applyIPv6Config(lxcName, c.IPv6); err != nil {
|
c.NormalizeNetworkAssignments()
|
||||||
|
if err := m.applyIPv6Config(lxcName, c.IPv6AddressStrings()...); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2440,8 +2557,8 @@ func (m *Manager) ReinstallContainer(id int, templateID string) error {
|
|||||||
// Set root password and pre-configure network/SSH via chroot.
|
// Set root password and pre-configure network/SSH via chroot.
|
||||||
rootfsPath := filepath.Join(m.LxcPath, lxcName, "rootfs")
|
rootfsPath := filepath.Join(m.LxcPath, lxcName, "rootfs")
|
||||||
m.preconfigureNetwork(rootfsPath, templateID)
|
m.preconfigureNetwork(rootfsPath, templateID)
|
||||||
if c.IPv6 != "" {
|
if c.IPv6 != "" || len(c.IPv6Addresses) > 0 {
|
||||||
if err := installContainerIPv6Init(rootfsPath, c.IPv6); err != nil {
|
if err := installContainerIPv6Init(rootfsPath, c.IPv6AddressStrings()...); err != nil {
|
||||||
fmt.Printf("Warning: failed to install IPv6 init in %s after reinstall: %v\n", lxcName, err)
|
fmt.Printf("Warning: failed to install IPv6 init in %s after reinstall: %v\n", lxcName, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2470,14 +2587,17 @@ func (m *Manager) ReinstallContainer(id int, templateID string) error {
|
|||||||
config.SaveConfig()
|
config.SaveConfig()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
logFile := filepath.Join(os.TempDir(), "clicd-"+lxcName+"-start.log")
|
logFile, consoleLog, output, err := m.startLXCContainerDaemon(lxcName)
|
||||||
os.Remove(logFile)
|
if err != nil {
|
||||||
startCmd := exec.Command("lxc-start", "-n", lxcName, "-d", "--logfile", logFile, "--logpriority", "DEBUG")
|
|
||||||
if output, err := startCmd.CombinedOutput(); err != nil {
|
|
||||||
fmt.Printf("Warning: failed to start container after reinstall: %v\n", err)
|
fmt.Printf("Warning: failed to start container after reinstall: %v\n", err)
|
||||||
c.Status = "stopped"
|
c.Status = "stopped"
|
||||||
config.SaveConfig()
|
config.SaveConfig()
|
||||||
return fmt.Errorf("reinstalled but failed to start: %v, output: %s, lxc log: %s", err, string(output), tailFile(logFile, 80))
|
return fmt.Errorf("reinstalled but failed to start: %v, output: %s, lxc log: %s, console: %s", err, string(output), tailFile(logFile, 80), tailFile(consoleLog, 80))
|
||||||
|
}
|
||||||
|
if err := m.waitForLXCStartup(lxcName, logFile, consoleLog); err != nil {
|
||||||
|
c.Status = "stopped"
|
||||||
|
config.SaveConfig()
|
||||||
|
return fmt.Errorf("reinstalled but container did not stay running: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for network and install SSH
|
// Wait for network and install SSH
|
||||||
@@ -2503,7 +2623,7 @@ func (m *Manager) ReinstallContainer(id int, templateID string) error {
|
|||||||
if c.NetworkBWMbps > 0 {
|
if c.NetworkBWMbps > 0 {
|
||||||
m.applyBandwidthLimit(c.LxcName(), c.NetworkBWMbps)
|
m.applyBandwidthLimit(c.LxcName(), c.NetworkBWMbps)
|
||||||
}
|
}
|
||||||
if c.IPv6 != "" {
|
if c.IPv6 != "" || len(c.IPv6Addresses) > 0 {
|
||||||
if err := m.ApplyIPv6(id); err != nil {
|
if err := m.ApplyIPv6(id); err != nil {
|
||||||
fmt.Printf("Warning: failed to apply IPv6 after reinstall: %v\n", err)
|
fmt.Printf("Warning: failed to apply IPv6 after reinstall: %v\n", err)
|
||||||
}
|
}
|
||||||
|
|||||||
+316
-16
@@ -2,8 +2,10 @@ package lxc
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/netip"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"clicd/internal/config"
|
"clicd/internal/config"
|
||||||
)
|
)
|
||||||
@@ -17,6 +19,7 @@ func (m *Manager) ApplyPortMappings(id int) error {
|
|||||||
if c.IP == "" {
|
if c.IP == "" {
|
||||||
return fmt.Errorf("container has no IP")
|
return fmt.Errorf("container has no IP")
|
||||||
}
|
}
|
||||||
|
EnsureAssignedPublicIPv4s(c.PublicIPv4s)
|
||||||
tag := clicdTag(id)
|
tag := clicdTag(id)
|
||||||
bridge := "lxcbr0"
|
bridge := "lxcbr0"
|
||||||
subnet := "10.0.3.0/24"
|
subnet := "10.0.3.0/24"
|
||||||
@@ -27,35 +30,180 @@ func (m *Manager) ApplyPortMappings(id int) error {
|
|||||||
|
|
||||||
EnsureForwardRules(bridge)
|
EnsureForwardRules(bridge)
|
||||||
m.CleanPortMappings(id)
|
m.CleanPortMappings(id)
|
||||||
|
deleteBridgeMasquerade(subnet)
|
||||||
|
|
||||||
for _, pm := range c.PortMappings {
|
for _, pm := range c.PortMappings {
|
||||||
cmd := exec.Command("iptables",
|
for _, hostIP := range expandPortMappingHostIPs(c, pm) {
|
||||||
|
args := []string{
|
||||||
"-t", "nat",
|
"-t", "nat",
|
||||||
"-I", "PREROUTING", "1",
|
"-I", "PREROUTING", "1",
|
||||||
"-p", pm.Protocol,
|
"-p", pm.Protocol,
|
||||||
|
}
|
||||||
|
if hostIP != "" {
|
||||||
|
args = append(args, "-d", hostIP)
|
||||||
|
}
|
||||||
|
args = append(args,
|
||||||
"--dport", fmt.Sprintf("%d", pm.HostPort),
|
"--dport", fmt.Sprintf("%d", pm.HostPort),
|
||||||
"-j", "DNAT",
|
"-j", "DNAT",
|
||||||
"--to-destination", fmt.Sprintf("%s:%d", c.IP, pm.ContainerPort),
|
"--to-destination", fmt.Sprintf("%s:%d", c.IP, pm.ContainerPort),
|
||||||
"-m", "comment", "--comment", fmt.Sprintf("clicd-%s-%d", tag, pm.HostPort),
|
"-m", "comment", "--comment", fmt.Sprintf("clicd-%s-%s-%d", tag, natRuleIPTag(hostIP), pm.HostPort),
|
||||||
)
|
)
|
||||||
|
cmd := exec.Command("iptables", args...)
|
||||||
output, err := cmd.CombinedOutput()
|
output, err := cmd.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("Warning: failed to apply port mapping %d->%s:%d: %v, output: %s\n",
|
fmt.Printf("Warning: failed to apply port mapping %s:%d->%s:%d: %v, output: %s\n",
|
||||||
pm.HostPort, c.IP, pm.ContainerPort, err, string(output))
|
displayHostIP(hostIP), pm.HostPort, c.IP, pm.ContainerPort, err, string(output))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
fmt.Printf("Port mapping: host:%d -> %s:%d\n", pm.HostPort, c.IP, pm.ContainerPort)
|
fmt.Printf("Port mapping: %s:%d -> %s:%d\n", displayHostIP(hostIP), pm.HostPort, c.IP, pm.ContainerPort)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if exec.Command("iptables", "-t", "nat", "-C", "POSTROUTING", "-s", subnet, "-o", "eth+", "-j", "MASQUERADE").Run() != nil {
|
applyIPv4EgressPolicy(c, bridge, subnet, tag)
|
||||||
exec.Command("iptables", "-t", "nat", "-I", "POSTROUTING", "1", "-s", subnet, "-o", "eth+", "-j", "MASQUERADE").Run()
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func applyIPv4EgressPolicy(c *config.Container, bridge, subnet, tag string) {
|
||||||
|
if c == nil || strings.TrimSpace(c.IP) == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if containerAllowsPublicIPv4Egress(c) {
|
||||||
|
if _, ok := primaryPublicIPv4Assignment(c); ok {
|
||||||
|
applyPublicIPv4SNAT(c, tag)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ensureContainerMasquerade(c, tag)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ensureIPv4EgressBlocked(c, bridge, subnet, tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
func containerAllowsPublicIPv4Egress(c *config.Container) bool {
|
||||||
|
if c == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if len(c.PublicIPv4s) > 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return c.PortMappingLimit > 0 || len(c.PortMappings) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureContainerMasquerade(c *config.Container, tag string) {
|
||||||
|
args := []string{
|
||||||
|
"-s", c.IP + "/32",
|
||||||
|
"-m", "comment", "--comment", fmt.Sprintf("clicd-%s-masq", tag),
|
||||||
|
"-j", "MASQUERADE",
|
||||||
|
}
|
||||||
|
if host := DetectPublicIPv4(); strings.TrimSpace(host.Interface) != "" {
|
||||||
|
args = append([]string{"-o", strings.TrimSpace(host.Interface)}, args...)
|
||||||
|
} else {
|
||||||
|
args = append([]string{"-o", "eth+"}, args...)
|
||||||
|
}
|
||||||
|
ensureNATRule("POSTROUTING", args)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureIPv4EgressBlocked(c *config.Container, bridge, subnet, tag string) {
|
||||||
|
args := []string{
|
||||||
|
"-i", bridge,
|
||||||
|
"-s", c.IP + "/32",
|
||||||
|
"!", "-d", subnet,
|
||||||
|
"-m", "comment", "--comment", fmt.Sprintf("clicd-%s-v4-egress-block", tag),
|
||||||
|
"-j", "REJECT",
|
||||||
|
}
|
||||||
|
ensureFilterRule("FORWARD", args)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureNATRule(chain string, args []string) {
|
||||||
|
check := append([]string{"-t", "nat", "-C", chain}, args...)
|
||||||
|
if exec.Command("iptables", check...).Run() == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
add := append([]string{"-t", "nat", "-I", chain, "1"}, args...)
|
||||||
|
exec.Command("iptables", add...).Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureFilterRule(chain string, args []string) {
|
||||||
|
check := append([]string{"-C", chain}, args...)
|
||||||
|
if exec.Command("iptables", check...).Run() == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
add := append([]string{"-I", chain, "1"}, args...)
|
||||||
|
exec.Command("iptables", add...).Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteBridgeMasquerade(subnet string) {
|
||||||
|
for exec.Command("iptables", "-t", "nat", "-D", "POSTROUTING", "-s", subnet, "-o", "eth+", "-j", "MASQUERADE").Run() == nil {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyPublicIPv4SNAT(c *config.Container, tag string) {
|
||||||
|
if c == nil || strings.TrimSpace(c.IP) == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
assignment, ok := primaryPublicIPv4Assignment(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hostIP := strings.TrimSpace(assignment.Address)
|
||||||
|
if hostIP == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
iface := strings.TrimSpace(assignment.Interface)
|
||||||
|
if iface == "" {
|
||||||
|
if info, ok := publicIPv4InfoByAddress(hostIP); ok {
|
||||||
|
iface = strings.TrimSpace(info.Interface)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if iface == "" {
|
||||||
|
if host := DetectPublicIPv4(); host.Interface != "" {
|
||||||
|
iface = host.Interface
|
||||||
|
}
|
||||||
|
}
|
||||||
|
args := []string{
|
||||||
|
"-t", "nat",
|
||||||
|
"-I", "POSTROUTING", "1",
|
||||||
|
"-s", c.IP + "/32",
|
||||||
|
}
|
||||||
|
if iface != "" {
|
||||||
|
args = append(args, "-o", iface)
|
||||||
|
}
|
||||||
|
args = append(args,
|
||||||
|
"-m", "comment", "--comment", fmt.Sprintf("clicd-%s-snat-%s", tag, natRuleIPTag(hostIP)),
|
||||||
|
"-j", "SNAT", "--to-source", hostIP,
|
||||||
|
)
|
||||||
|
if output, err := exec.Command("iptables", args...).CombinedOutput(); err != nil {
|
||||||
|
fmt.Printf("Warning: failed to apply public IPv4 SNAT %s -> %s: %v, output: %s\n", c.IP, hostIP, err, string(output))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func primaryPublicIPv4Assignment(c *config.Container) (config.PublicIPv4Assignment, bool) {
|
||||||
|
if c == nil {
|
||||||
|
return config.PublicIPv4Assignment{}, false
|
||||||
|
}
|
||||||
|
for _, item := range c.PublicIPv4s {
|
||||||
|
if strings.TrimSpace(item.Address) != "" {
|
||||||
|
return item, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return config.PublicIPv4Assignment{}, false
|
||||||
|
}
|
||||||
|
|
||||||
func clicdTag(id int) string { return "c" + strconv.Itoa(id) }
|
func clicdTag(id int) string { return "c" + strconv.Itoa(id) }
|
||||||
|
|
||||||
|
func EnsureAllRunningPortMappings() {
|
||||||
|
m := NewManager()
|
||||||
|
for i := range config.AppConfig.Containers {
|
||||||
|
c := &config.AppConfig.Containers[i]
|
||||||
|
if c.Status != "running" || strings.TrimSpace(c.IP) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := m.ApplyPortMappings(c.ID); err != nil {
|
||||||
|
fmt.Printf("Warning: failed to restore port mappings for %s: %v\n", c.Name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// EnsureForwardRules makes sure iptables FORWARD chain allows bridge traffic.
|
// EnsureForwardRules makes sure iptables FORWARD chain allows bridge traffic.
|
||||||
func EnsureForwardRules(bridge string) {
|
func EnsureForwardRules(bridge string) {
|
||||||
if bridge == "" {
|
if bridge == "" {
|
||||||
@@ -81,8 +229,13 @@ func EnsureForwardRules(bridge string) {
|
|||||||
// CleanPortMappings removes all iptables rules for a container
|
// CleanPortMappings removes all iptables rules for a container
|
||||||
func (m *Manager) CleanPortMappings(id int) error {
|
func (m *Manager) CleanPortMappings(id int) error {
|
||||||
tag := clicdTag(id)
|
tag := clicdTag(id)
|
||||||
|
for _, chain := range []string{"PREROUTING", "POSTROUTING"} {
|
||||||
cmd := exec.Command("sh", "-c",
|
cmd := exec.Command("sh", "-c",
|
||||||
fmt.Sprintf("iptables -t nat -L PREROUTING -n --line-numbers 2>/dev/null | grep 'clicd-%s' | awk '{print $1}' | sort -rn | while read num; do iptables -t nat -D PREROUTING $num; done", tag))
|
fmt.Sprintf("iptables -t nat -L %s -n --line-numbers 2>/dev/null | grep 'clicd-%s-' | awk '{print $1}' | sort -rn | while read num; do iptables -t nat -D %s $num; done", chain, tag, chain))
|
||||||
|
cmd.Run()
|
||||||
|
}
|
||||||
|
cmd := exec.Command("sh", "-c",
|
||||||
|
fmt.Sprintf("iptables -S FORWARD 2>/dev/null | grep 'clicd-%s-' | sed 's/^-A /-D /' | while read rule; do iptables $rule; done", tag))
|
||||||
cmd.Run()
|
cmd.Run()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -94,12 +247,26 @@ func SetupDefaultPortMappings(sshPort int) []config.PortMapping {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func DefaultPortMappingHostIP(assignments []config.PublicIPv4Assignment) string {
|
||||||
|
if len(assignments) == 1 {
|
||||||
|
return strings.TrimSpace(assignments[0].Address)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultPortMappingHostIP(assignments []config.PublicIPv4Assignment) string {
|
||||||
|
return DefaultPortMappingHostIP(assignments)
|
||||||
|
}
|
||||||
|
|
||||||
// AddPortMapping adds a NAT rule to a container
|
// AddPortMapping adds a NAT rule to a container
|
||||||
func (m *Manager) AddPortMapping(id int, pm config.PortMapping) ([]config.PortMapping, error) {
|
func (m *Manager) AddPortMapping(id int, pm config.PortMapping) ([]config.PortMapping, error) {
|
||||||
c := config.FindContainer(id)
|
c := config.FindContainer(id)
|
||||||
if c == nil {
|
if c == nil {
|
||||||
return nil, fmt.Errorf("container not found: %d", id)
|
return nil, fmt.Errorf("container not found: %d", id)
|
||||||
}
|
}
|
||||||
|
if c.PortMappingLimit <= 0 {
|
||||||
|
return nil, fmt.Errorf("container has no IPv4 NAT port quota")
|
||||||
|
}
|
||||||
if c.PortMappingLimit > 0 && len(c.PortMappings) >= c.PortMappingLimit {
|
if c.PortMappingLimit > 0 && len(c.PortMappings) >= c.PortMappingLimit {
|
||||||
return nil, fmt.Errorf("port mapping quota exceeded: %d/%d", len(c.PortMappings), c.PortMappingLimit)
|
return nil, fmt.Errorf("port mapping quota exceeded: %d/%d", len(c.PortMappings), c.PortMappingLimit)
|
||||||
}
|
}
|
||||||
@@ -168,6 +335,17 @@ func normalizePortMapping(c *config.Container, skipIndex int, pm config.PortMapp
|
|||||||
if pm.Protocol == "" {
|
if pm.Protocol == "" {
|
||||||
pm.Protocol = "tcp"
|
pm.Protocol = "tcp"
|
||||||
}
|
}
|
||||||
|
pm.Protocol = strings.ToLower(strings.TrimSpace(pm.Protocol))
|
||||||
|
pm.HostIP = strings.TrimSpace(pm.HostIP)
|
||||||
|
if pm.HostIP != "" {
|
||||||
|
addr, err := netip.ParseAddr(pm.HostIP)
|
||||||
|
if err != nil || !addr.Is4() {
|
||||||
|
return pm, fmt.Errorf("host_ip must be a valid IPv4 address")
|
||||||
|
}
|
||||||
|
if !containerHasPublicIPv4(c, pm.HostIP) {
|
||||||
|
return pm, fmt.Errorf("host_ip %s is not assigned to this container", pm.HostIP)
|
||||||
|
}
|
||||||
|
}
|
||||||
if pm.Description == "" {
|
if pm.Description == "" {
|
||||||
pm.Description = fmt.Sprintf("Port-%d", pm.ContainerPort)
|
pm.Description = fmt.Sprintf("Port-%d", pm.ContainerPort)
|
||||||
}
|
}
|
||||||
@@ -179,8 +357,8 @@ func normalizePortMapping(c *config.Container, skipIndex int, pm config.PortMapp
|
|||||||
if i == skipIndex {
|
if i == skipIndex {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if existing.HostPort == pm.HostPort && existing.Protocol == pm.Protocol {
|
if portMappingsConflict(c, pm, c, existing) {
|
||||||
return pm, fmt.Errorf("host port %d/%s already mapped in this container", pm.HostPort, pm.Protocol)
|
return pm, fmt.Errorf("host port %d/%s already mapped on the same IPv4 in this container", pm.HostPort, pm.Protocol)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Check all other containers (LXC + KVM) for port conflicts
|
// Check all other containers (LXC + KVM) for port conflicts
|
||||||
@@ -189,8 +367,9 @@ func normalizePortMapping(c *config.Container, skipIndex int, pm config.PortMapp
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for _, existing := range oc.PortMappings {
|
for _, existing := range oc.PortMappings {
|
||||||
if existing.HostPort == pm.HostPort && existing.Protocol == pm.Protocol {
|
oc := oc
|
||||||
return pm, fmt.Errorf("host port %d/%s already used by container %s (ID: %d)", pm.HostPort, pm.Protocol, oc.Name, oc.ID)
|
if portMappingsConflict(c, pm, &oc, existing) {
|
||||||
|
return pm, fmt.Errorf("host port %d/%s already used on the same IPv4 by container %s (ID: %d)", pm.HostPort, pm.Protocol, oc.Name, oc.ID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -204,7 +383,9 @@ func allocateDefaultEqualPorts(c *config.Container, count int) []int {
|
|||||||
used := map[int]bool{}
|
used := map[int]bool{}
|
||||||
// Mark current container's ports
|
// Mark current container's ports
|
||||||
for _, pm := range c.PortMappings {
|
for _, pm := range c.PortMappings {
|
||||||
used[pm.HostPort] = true
|
for _, hostIP := range expandPortMappingHostIPs(c, pm) {
|
||||||
|
used[hostPortKey(hostIP, pm.HostPort)] = true
|
||||||
|
}
|
||||||
used[pm.ContainerPort] = true
|
used[pm.ContainerPort] = true
|
||||||
}
|
}
|
||||||
// Also mark all other containers' host ports (LXC + KVM)
|
// Also mark all other containers' host ports (LXC + KVM)
|
||||||
@@ -213,13 +394,17 @@ func allocateDefaultEqualPorts(c *config.Container, count int) []int {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for _, pm := range oc.PortMappings {
|
for _, pm := range oc.PortMappings {
|
||||||
used[pm.HostPort] = true
|
oc := oc
|
||||||
|
for _, hostIP := range expandPortMappingHostIPs(&oc, pm) {
|
||||||
|
used[hostPortKey(hostIP, pm.HostPort)] = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ports := make([]int, 0, count)
|
ports := make([]int, 0, count)
|
||||||
next := 20000
|
next := 20000
|
||||||
for len(ports) < count {
|
for len(ports) < count {
|
||||||
if !used[next] {
|
hostIP := c.PrimaryPublicIPv4()
|
||||||
|
if !used[hostPortKey(hostIP, next)] && !used[next] {
|
||||||
ports = append(ports, next)
|
ports = append(ports, next)
|
||||||
}
|
}
|
||||||
next++
|
next++
|
||||||
@@ -229,3 +414,118 @@ func allocateDefaultEqualPorts(c *config.Container, count int) []int {
|
|||||||
}
|
}
|
||||||
return ports
|
return ports
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func HostPortAvailable(c *config.Container, hostIP string, hostPort int, protocol string) bool {
|
||||||
|
if c == nil || hostPort <= 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
pm := config.PortMapping{HostIP: strings.TrimSpace(hostIP), HostPort: hostPort, Protocol: protocol}
|
||||||
|
for _, existing := range c.PortMappings {
|
||||||
|
if portMappingsConflict(c, pm, c, existing) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, oc := range config.AppConfig.Containers {
|
||||||
|
if oc.ID == c.ID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
oc := oc
|
||||||
|
for _, existing := range oc.PortMappings {
|
||||||
|
if portMappingsConflict(c, pm, &oc, existing) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func expandPortMappingHostIPs(c *config.Container, pm config.PortMapping) []string {
|
||||||
|
if strings.TrimSpace(pm.HostIP) != "" {
|
||||||
|
return []string{strings.TrimSpace(pm.HostIP)}
|
||||||
|
}
|
||||||
|
if c != nil && len(c.PublicIPv4s) > 0 {
|
||||||
|
values := make([]string, 0, len(c.PublicIPv4s))
|
||||||
|
for _, item := range c.PublicIPv4s {
|
||||||
|
if strings.TrimSpace(item.Address) != "" {
|
||||||
|
values = append(values, strings.TrimSpace(item.Address))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(values) > 0 {
|
||||||
|
return values
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return []string{""}
|
||||||
|
}
|
||||||
|
|
||||||
|
func containerHasPublicIPv4(c *config.Container, hostIP string) bool {
|
||||||
|
if c == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, item := range c.PublicIPv4s {
|
||||||
|
if item.Address == hostIP {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func portMappingsConflict(aContainer *config.Container, a config.PortMapping, bContainer *config.Container, b config.PortMapping) bool {
|
||||||
|
if a.HostPort != b.HostPort || !protocolsOverlap(a.Protocol, b.Protocol) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
aIPs := expandPortMappingHostIPs(aContainer, a)
|
||||||
|
bIPs := expandPortMappingHostIPs(bContainer, b)
|
||||||
|
for _, aIP := range aIPs {
|
||||||
|
for _, bIP := range bIPs {
|
||||||
|
if aIP == "" || bIP == "" || aIP == bIP {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func protocolsOverlap(a, b string) bool {
|
||||||
|
a = strings.ToLower(strings.TrimSpace(a))
|
||||||
|
b = strings.ToLower(strings.TrimSpace(b))
|
||||||
|
if a == "" {
|
||||||
|
a = "tcp"
|
||||||
|
}
|
||||||
|
if b == "" {
|
||||||
|
b = "tcp"
|
||||||
|
}
|
||||||
|
if a == b || a == "all" || b == "all" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return (a == "tcp+udp" && (b == "tcp" || b == "udp")) ||
|
||||||
|
(b == "tcp+udp" && (a == "tcp" || a == "udp"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func natRuleIPTag(ip string) string {
|
||||||
|
ip = strings.TrimSpace(ip)
|
||||||
|
if ip == "" {
|
||||||
|
return "any"
|
||||||
|
}
|
||||||
|
return strings.ReplaceAll(ip, ".", "_")
|
||||||
|
}
|
||||||
|
|
||||||
|
func displayHostIP(ip string) string {
|
||||||
|
if strings.TrimSpace(ip) == "" {
|
||||||
|
return "host"
|
||||||
|
}
|
||||||
|
return ip
|
||||||
|
}
|
||||||
|
|
||||||
|
func hostPortKey(hostIP string, port int) int {
|
||||||
|
if hostIP == "" {
|
||||||
|
return port
|
||||||
|
}
|
||||||
|
sum := 0
|
||||||
|
for _, r := range hostIP {
|
||||||
|
sum = sum*31 + int(r)
|
||||||
|
}
|
||||||
|
if sum < 0 {
|
||||||
|
sum = -sum
|
||||||
|
}
|
||||||
|
return port + (sum % 1000000 * 100000)
|
||||||
|
}
|
||||||
|
|||||||
@@ -46,17 +46,17 @@ func GetTemplates() []Template {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
ID: "archlinux-current", Name: "Arch Linux",
|
ID: "archlinux-current", Name: "Arch Linux",
|
||||||
Distro: "archlinux", Release: "current", Arch: "amd64", Variant: "cloud",
|
Distro: "archlinux", Release: "current", Arch: "amd64",
|
||||||
Description: "Arch Linux (Rolling)",
|
Description: "Arch Linux (Rolling)",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ID: "fedora-44", Name: "Fedora 44",
|
ID: "fedora-44", Name: "Fedora 44",
|
||||||
Distro: "fedora", Release: "44", Arch: "amd64", Variant: "cloud",
|
Distro: "fedora", Release: "44", Arch: "amd64",
|
||||||
Description: "Fedora 44",
|
Description: "Fedora 44",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ID: "rockylinux-10", Name: "Rocky Linux 10",
|
ID: "rockylinux-10", Name: "Rocky Linux 10",
|
||||||
Distro: "rockylinux", Release: "10", Arch: "amd64", Variant: "cloud",
|
Distro: "rockylinux", Release: "10", Arch: "amd64",
|
||||||
Description: "Rocky Linux 10",
|
Description: "Rocky Linux 10",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ func setupRoutes(mux *http.ServeMux) {
|
|||||||
mux.HandleFunc("/api/host-info", corsMiddleware(api.AdminMiddleware(api.HandleHostInfo)))
|
mux.HandleFunc("/api/host-info", corsMiddleware(api.AdminMiddleware(api.HandleHostInfo)))
|
||||||
mux.HandleFunc("/api/host-report", corsMiddleware(api.AdminMiddleware(api.HandleHostReport)))
|
mux.HandleFunc("/api/host-report", corsMiddleware(api.AdminMiddleware(api.HandleHostReport)))
|
||||||
mux.HandleFunc("/api/snapshots", corsMiddleware(api.AdminMiddleware(api.HandleSnapshots)))
|
mux.HandleFunc("/api/snapshots", corsMiddleware(api.AdminMiddleware(api.HandleSnapshots)))
|
||||||
|
mux.HandleFunc("/api/routing/ipv4-scan", corsMiddleware(api.AdminMiddleware(api.HandleRoutingIPv4Scan)))
|
||||||
mux.HandleFunc("/api/routing", corsMiddleware(api.AdminMiddleware(api.HandleRouting)))
|
mux.HandleFunc("/api/routing", corsMiddleware(api.AdminMiddleware(api.HandleRouting)))
|
||||||
mux.HandleFunc("/api/ipv6/status", corsMiddleware(api.AdminMiddleware(api.HandleIPv6Status)))
|
mux.HandleFunc("/api/ipv6/status", corsMiddleware(api.AdminMiddleware(api.HandleIPv6Status)))
|
||||||
mux.HandleFunc("/api/tasks", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleTasks))))
|
mux.HandleFunc("/api/tasks", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleTasks))))
|
||||||
@@ -134,6 +135,7 @@ func setupRoutes(mux *http.ServeMux) {
|
|||||||
mux.HandleFunc("/api/v1/host-info", corsMiddleware(api.AuthMiddleware(api.HandleHostInfo)))
|
mux.HandleFunc("/api/v1/host-info", corsMiddleware(api.AuthMiddleware(api.HandleHostInfo)))
|
||||||
mux.HandleFunc("/api/v1/host-report", corsMiddleware(api.AuthMiddleware(api.HandleHostReport)))
|
mux.HandleFunc("/api/v1/host-report", corsMiddleware(api.AuthMiddleware(api.HandleHostReport)))
|
||||||
mux.HandleFunc("/api/v1/snapshots", corsMiddleware(api.AuthMiddleware(api.ScopeMiddleware("snapshot:read", api.HandleSnapshots))))
|
mux.HandleFunc("/api/v1/snapshots", corsMiddleware(api.AuthMiddleware(api.ScopeMiddleware("snapshot:read", api.HandleSnapshots))))
|
||||||
|
mux.HandleFunc("/api/v1/routing/ipv4-scan", corsMiddleware(api.AuthMiddleware(api.HandleRoutingIPv4Scan)))
|
||||||
mux.HandleFunc("/api/v1/routing", corsMiddleware(api.AuthMiddleware(api.HandleRouting)))
|
mux.HandleFunc("/api/v1/routing", corsMiddleware(api.AuthMiddleware(api.HandleRouting)))
|
||||||
mux.HandleFunc("/api/v1/ipv6/status", corsMiddleware(api.AuthMiddleware(api.HandleIPv6Status)))
|
mux.HandleFunc("/api/v1/ipv6/status", corsMiddleware(api.AuthMiddleware(api.HandleIPv6Status)))
|
||||||
mux.HandleFunc("/api/v1/tasks", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleTasks))))
|
mux.HandleFunc("/api/v1/tasks", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleTasks))))
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ func main() {
|
|||||||
// Ensure iptables FORWARD rules allow managed bridge traffic.
|
// Ensure iptables FORWARD rules allow managed bridge traffic.
|
||||||
lxc.EnsureForwardRules("lxcbr0")
|
lxc.EnsureForwardRules("lxcbr0")
|
||||||
lxc.EnsureForwardRules("virbr0")
|
lxc.EnsureForwardRules("virbr0")
|
||||||
|
lxc.EnsureAllAssignedPublicIPv4s()
|
||||||
|
|
||||||
// Start expiry scanners (stops expired/over-traffic workloads every 30s)
|
// Start expiry scanners (stops expired/over-traffic workloads every 30s)
|
||||||
manager := lxc.NewManager()
|
manager := lxc.NewManager()
|
||||||
@@ -74,6 +75,7 @@ func main() {
|
|||||||
|
|
||||||
// Clean up stale container configs (LXC dir was deleted but config remains)
|
// Clean up stale container configs (LXC dir was deleted but config remains)
|
||||||
config.CleanStaleContainers()
|
config.CleanStaleContainers()
|
||||||
|
lxc.EnsureAllRunningPortMappings()
|
||||||
|
|
||||||
// Pre-warm SSH for containers already running after host boot or service restart.
|
// Pre-warm SSH for containers already running after host boot or service restart.
|
||||||
manager.StartSSHWarmupScanner()
|
manager.StartSSHWarmupScanner()
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState, type ReactNode } from 'react'
|
|||||||
import { CalendarClock, X } from 'lucide-react'
|
import { CalendarClock, X } from 'lucide-react'
|
||||||
import { batchCreate, getIPv6Status, getEnabledImages, getHostInfo, CreateContainerRequest, HostInfo, IPv6Status, Template } from '../services/api'
|
import { batchCreate, getIPv6Status, getEnabledImages, getHostInfo, CreateContainerRequest, HostInfo, IPv6Status, Template } from '../services/api'
|
||||||
import { useDialog } from './Dialog'
|
import { useDialog } from './Dialog'
|
||||||
|
import { useLanguage, type Language } from '../contexts/LanguageContext'
|
||||||
|
|
||||||
interface CreateContainerModalProps {
|
interface CreateContainerModalProps {
|
||||||
isOpen: boolean
|
isOpen: boolean
|
||||||
@@ -26,13 +27,21 @@ const defaultForm: CreateContainerRequest = {
|
|||||||
io_speed_mbps: 0,
|
io_speed_mbps: 0,
|
||||||
extra_ports: [],
|
extra_ports: [],
|
||||||
port_mapping_count: 2,
|
port_mapping_count: 2,
|
||||||
|
assign_nat: true,
|
||||||
snapshot_limit: 1,
|
snapshot_limit: 1,
|
||||||
|
assign_ipv4: false,
|
||||||
|
ipv4_count: 1,
|
||||||
|
public_ipv4s: [],
|
||||||
assign_ipv6: false,
|
assign_ipv6: false,
|
||||||
|
ipv6_count: 1,
|
||||||
|
ipv6_addresses: [],
|
||||||
expires_at: '',
|
expires_at: '',
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CreateContainerModal({ isOpen, onClose, onSuccess, existingNames = [] }: CreateContainerModalProps) {
|
export default function CreateContainerModal({ isOpen, onClose, onSuccess, existingNames = [] }: CreateContainerModalProps) {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
|
const { language } = useLanguage()
|
||||||
|
const networkText = createNetworkText[language]
|
||||||
const [templates, setTemplates] = useState<Template[]>([])
|
const [templates, setTemplates] = useState<Template[]>([])
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [batchCount, setBatchCount] = useState(1)
|
const [batchCount, setBatchCount] = useState(1)
|
||||||
@@ -74,16 +83,23 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
|||||||
}, [isOpen, form.virtualization])
|
}, [isOpen, form.virtualization])
|
||||||
|
|
||||||
const ipv6Available = !!ipv6Status?.available
|
const ipv6Available = !!ipv6Status?.available
|
||||||
const ipv6Prefix = ipv6Status?.prefixes?.[0]?.prefix || ''
|
const ipv6Prefixes = ipv6Status?.prefixes || []
|
||||||
|
const ipv6Prefix = ipv6Prefixes.length > 1 ? `${ipv6Prefixes.length} prefixes configured` : (ipv6Prefixes[0]?.prefix || '')
|
||||||
|
const publicIPv4s = hostInfo?.network.public_ipv4_addresses || []
|
||||||
|
const ipv4Available = publicIPv4s.length > 0
|
||||||
|
const manualIPv4s = form.public_ipv4s || []
|
||||||
const maxVCPU = hostInfo?.cpu.cores || 64
|
const maxVCPU = hostInfo?.cpu.cores || 64
|
||||||
const maxRAMMB = hostInfo?.ram.total_mb ? Number(hostInfo.ram.total_mb) : undefined
|
const maxRAMMB = hostInfo?.ram.total_mb ? Number(hostInfo.ram.total_mb) : undefined
|
||||||
const maxDiskGB = hostInfo?.disk.total_gb ? Math.max(1, Math.floor(hostInfo.disk.total_gb)) : undefined
|
const maxDiskGB = hostInfo?.disk.total_gb ? Math.max(1, Math.floor(hostInfo.disk.total_gb)) : undefined
|
||||||
const resourceErrors = validateResourceInputs(form, maxVCPU, maxRAMMB, maxDiskGB)
|
const resourceErrors = validateResourceInputs(form, maxVCPU, maxRAMMB, maxDiskGB)
|
||||||
|
const natEnabled = form.assign_nat !== false
|
||||||
|
const natPortCount = natEnabled ? Math.max(2, form.port_mapping_count || 2) : 0
|
||||||
|
|
||||||
const autoPorts = useMemo(() => {
|
const autoPorts = useMemo(() => {
|
||||||
const count = Math.max(2, form.port_mapping_count)
|
if (!natEnabled) return []
|
||||||
|
const count = natPortCount
|
||||||
return Array.from({ length: count - 1 }, (_, index) => 22002 + index)
|
return Array.from({ length: count - 1 }, (_, index) => 22002 + index)
|
||||||
}, [form.port_mapping_count])
|
}, [natEnabled, natPortCount])
|
||||||
|
|
||||||
// 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
|
||||||
@@ -127,7 +143,13 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!form.assign_ipv4 && !form.assign_ipv6 && form.assign_nat === false) {
|
||||||
|
dialog.alert('提示', '请勾选任意一个可用网络')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const boundedForm = normalizeCreateForm(form)
|
const boundedForm = normalizeCreateForm(form)
|
||||||
|
const wantsNAT = boundedForm.assign_nat !== false
|
||||||
|
|
||||||
// Build batch of containers
|
// Build batch of containers
|
||||||
const containers: CreateContainerRequest[] = []
|
const containers: CreateContainerRequest[] = []
|
||||||
@@ -137,8 +159,11 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
|||||||
containers.push({
|
containers.push({
|
||||||
...boundedForm,
|
...boundedForm,
|
||||||
name,
|
name,
|
||||||
port_mapping_count: Math.max(2, boundedForm.port_mapping_count || 2),
|
assign_nat: wantsNAT,
|
||||||
|
port_mapping_count: wantsNAT ? Math.max(2, boundedForm.port_mapping_count || 2) : 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,
|
||||||
|
ipv6_count: boundedForm.assign_ipv6 ? Math.max(1, boundedForm.ipv6_count || 1) : 0,
|
||||||
extra_ports: [],
|
extra_ports: [],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -229,7 +254,80 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
|||||||
|
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<label className={`flex items-start gap-3 rounded-md border px-3 py-2 text-sm ${ipv6Available ? '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">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={!!form.assign_ipv4}
|
||||||
|
disabled={!ipv4Available}
|
||||||
|
onChange={(event) => setForm({ ...form, assign_ipv4: event.target.checked, public_ipv4s: event.target.checked ? form.public_ipv4s : [] })}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block font-medium text-gray-800">{networkText.publicIPv4}</span>
|
||||||
|
<span className="block text-xs text-gray-500">
|
||||||
|
{ipv4Available ? formatAllocatableIPv4Count(publicIPv4s.length, language) : networkText.noAllocatableIPv4}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
{form.assign_ipv4 && (
|
||||||
|
<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={manualIPv4s.length === 0}
|
||||||
|
onChange={() => setForm({ ...form, public_ipv4s: [] })}
|
||||||
|
/>
|
||||||
|
Auto assign
|
||||||
|
</label>
|
||||||
|
<Field label="IPv4 count">
|
||||||
|
<NumberInput
|
||||||
|
value={form.ipv4_count || 1}
|
||||||
|
min={1}
|
||||||
|
max={Math.max(1, publicIPv4s.length)}
|
||||||
|
onChange={(value) => setForm({ ...form, ipv4_count: Math.max(1, Math.round(value || 1)) })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="flex items-center gap-2 text-xs text-gray-600">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
checked={manualIPv4s.length > 0}
|
||||||
|
onChange={() => setForm({ ...form, public_ipv4s: publicIPv4s[0]?.address ? [publicIPv4s[0].address] : [], ipv4_count: 1 })}
|
||||||
|
/>
|
||||||
|
Manual select
|
||||||
|
</label>
|
||||||
|
{manualIPv4s.length > 0 && (
|
||||||
|
<div className="grid gap-1.5 sm:grid-cols-2">
|
||||||
|
{publicIPv4s.map((ip) => (
|
||||||
|
<label key={`${ip.interface}-${ip.address}`} className="flex min-w-0 items-center gap-2 rounded border border-gray-200 px-2 py-1.5 text-xs text-gray-700">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={manualIPv4s.includes(ip.address)}
|
||||||
|
onChange={(event) => {
|
||||||
|
const next = event.target.checked
|
||||||
|
? [...manualIPv4s, ip.address]
|
||||||
|
: manualIPv4s.filter((value) => value !== ip.address)
|
||||||
|
setForm({ ...form, public_ipv4s: next, ipv4_count: 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>
|
||||||
|
|
||||||
|
<div className={`rounded-md border px-3 py-2 text-sm ${ipv6Available ? 'border-gray-200 bg-white' : 'border-gray-200 bg-gray-50 text-gray-400'}`}>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<label className="flex min-w-0 flex-1 items-start gap-3">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={!!form.assign_ipv6}
|
checked={!!form.assign_ipv6}
|
||||||
@@ -238,12 +336,75 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
|||||||
className="mt-1"
|
className="mt-1"
|
||||||
/>
|
/>
|
||||||
<span className="min-w-0">
|
<span className="min-w-0">
|
||||||
<span className="block font-medium text-gray-800">Public IPv6</span>
|
<span className="block font-medium text-gray-800">{networkText.publicIPv6}</span>
|
||||||
<span className="block text-xs text-gray-500 truncate">
|
<span className="block text-xs text-gray-500 truncate">
|
||||||
{ipv6Available ? `Use ${ipv6Prefix}` : (ipv6Status?.reason || 'Checking IPv6 prefix...')}
|
{ipv6Available ? `${networkText.use} ${ipv6Prefix}` : (ipv6Status?.reason || networkText.checkingIPv6Prefix)}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
{form.assign_ipv6 && (
|
||||||
|
<span className="block w-24 shrink-0">
|
||||||
|
<NumberInput
|
||||||
|
value={form.ipv6_count || 1}
|
||||||
|
min={1}
|
||||||
|
max={64}
|
||||||
|
onChange={(value) => setForm({ ...form, ipv6_count: Math.max(1, Math.round(value || 1)) })}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-md border border-gray-200 bg-white px-3 py-2 text-sm">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<label className="flex min-w-0 flex-1 items-start gap-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={natEnabled}
|
||||||
|
onChange={(event) => {
|
||||||
|
const checked = event.target.checked
|
||||||
|
setForm({
|
||||||
|
...form,
|
||||||
|
assign_nat: checked,
|
||||||
|
port_mapping_count: checked ? Math.max(2, form.port_mapping_count || 2) : 0,
|
||||||
|
extra_ports: [],
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block font-medium text-gray-800">{networkText.publicNAT}</span>
|
||||||
|
<span className="block text-xs text-gray-500">
|
||||||
|
{natEnabled ? formatNATPortCount(natPortCount, language) : networkText.noNATPorts}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
{natEnabled && (
|
||||||
|
<span className="block w-24 shrink-0">
|
||||||
|
<NumberInput
|
||||||
|
value={natPortCount}
|
||||||
|
min={2}
|
||||||
|
max={64}
|
||||||
|
onChange={(value) => setForm({ ...form, port_mapping_count: Math.max(2, value || 2), assign_nat: true })}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{natEnabled && (
|
||||||
|
<div className="mt-2 pl-6">
|
||||||
|
<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">
|
||||||
|
{isWindowsTemplate(form.template_id) ? 'RDP' : 'SSH'}: {sshPortPreview} -> {isWindowsTemplate(form.template_id) ? 3389 : 22}
|
||||||
|
</span>
|
||||||
|
{autoPorts.map((port) => (
|
||||||
|
<span key={port} className="inline-flex px-2 py-1 bg-gray-100 text-gray-700 rounded text-xs font-mono">
|
||||||
|
{port} -> {port}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<Field label="vCPU">
|
<Field label="vCPU">
|
||||||
@@ -319,25 +480,6 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Field label="NAT 端口映射数量">
|
|
||||||
<NumberInput
|
|
||||||
value={form.port_mapping_count}
|
|
||||||
min={2}
|
|
||||||
max={64}
|
|
||||||
onChange={(value) => setForm({ ...form, port_mapping_count: Math.max(2, value || 2) })}
|
|
||||||
/>
|
|
||||||
<div className="mt-2 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">
|
|
||||||
{isWindowsTemplate(form.template_id) ? 'RDP' : 'SSH'}: {sshPortPreview} -> {isWindowsTemplate(form.template_id) ? 3389 : 22}
|
|
||||||
</span>
|
|
||||||
{autoPorts.map((port) => (
|
|
||||||
<span key={port} className="inline-flex px-2 py-1 bg-gray-100 text-gray-700 rounded text-xs font-mono">
|
|
||||||
{port} -> {port}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field label="子用户快照上限">
|
<Field label="子用户快照上限">
|
||||||
<NumberInput
|
<NumberInput
|
||||||
value={form.snapshot_limit}
|
value={form.snapshot_limit}
|
||||||
@@ -475,11 +617,22 @@ function validateResourceInputs(form: CreateContainerRequest, maxVCPU: number, m
|
|||||||
|
|
||||||
function normalizeCreateForm(form: CreateContainerRequest): CreateContainerRequest {
|
function normalizeCreateForm(form: CreateContainerRequest): CreateContainerRequest {
|
||||||
const normalized = applyTemplateDefaults(form)
|
const normalized = applyTemplateDefaults(form)
|
||||||
|
const wantsNAT = normalized.assign_nat !== false
|
||||||
|
const wantsIPv4 = !!normalized.assign_ipv4
|
||||||
|
const wantsIPv6 = !!normalized.assign_ipv6
|
||||||
return {
|
return {
|
||||||
...normalized,
|
...normalized,
|
||||||
vcpu: normalized.virtualization === 'kvm' ? Math.round(normalized.vcpu) : normalizeLXCvCPU(normalized.vcpu),
|
vcpu: normalized.virtualization === 'kvm' ? Math.round(normalized.vcpu) : normalizeLXCvCPU(normalized.vcpu),
|
||||||
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,
|
||||||
|
port_mapping_count: wantsNAT ? clampInt(normalized.port_mapping_count, 2, 64, 2) : 0,
|
||||||
|
assign_ipv4: wantsIPv4,
|
||||||
|
ipv4_count: wantsIPv4 ? clampInt(normalized.ipv4_count || 1, 1, 64, 1) : 0,
|
||||||
|
public_ipv4s: wantsIPv4 ? (normalized.public_ipv4s || []) : [],
|
||||||
|
assign_ipv6: wantsIPv6,
|
||||||
|
ipv6_count: wantsIPv6 ? clampInt(normalized.ipv6_count || 1, 1, 64, 1) : 0,
|
||||||
|
ipv6_addresses: wantsIPv6 ? (normalized.ipv6_addresses || []) : [],
|
||||||
snapshot_limit: clampInt(normalized.snapshot_limit, 1, undefined, 3),
|
snapshot_limit: clampInt(normalized.snapshot_limit, 1, undefined, 3),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -509,5 +662,38 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const createNetworkText = {
|
||||||
|
zh: {
|
||||||
|
publicIPv4: '公网 IPv4',
|
||||||
|
noAllocatableIPv4: '未检测到可分配公网 IPv4',
|
||||||
|
publicIPv6: '公网 IPv6',
|
||||||
|
use: '使用',
|
||||||
|
checkingIPv6Prefix: '正在检测 IPv6 前缀...',
|
||||||
|
publicNAT: '公网 NAT',
|
||||||
|
noNATPorts: '不分配 NAT 端口',
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
publicIPv4: 'Public IPv4',
|
||||||
|
noAllocatableIPv4: 'No allocatable public IPv4 detected',
|
||||||
|
publicIPv6: 'Public IPv6',
|
||||||
|
use: 'Use',
|
||||||
|
checkingIPv6Prefix: 'Checking IPv6 prefix...',
|
||||||
|
publicNAT: 'Public NAT',
|
||||||
|
noNATPorts: 'No NAT ports will be assigned',
|
||||||
|
},
|
||||||
|
} as const
|
||||||
|
|
||||||
|
function formatAllocatableIPv4Count(count: number, language: Language) {
|
||||||
|
return language === 'en'
|
||||||
|
? `${count} allocatable address${count === 1 ? '' : 'es'} detected`
|
||||||
|
: `检测到 ${count} 个可分配地址`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNATPortCount(count: number, language: Language) {
|
||||||
|
return language === 'en'
|
||||||
|
? `${count} NAT ports will be assigned`
|
||||||
|
: `将分配 ${count} 个 NAT 端口`
|
||||||
|
}
|
||||||
|
|
||||||
const inputClass =
|
const inputClass =
|
||||||
'w-full px-3 py-2 border border-gray-300 rounded-md text-sm text-black bg-white focus:outline-none focus:ring-2 focus:ring-black focus:border-black'
|
'w-full px-3 py-2 border border-gray-300 rounded-md text-sm text-black bg-white focus:outline-none focus:ring-2 focus:ring-black focus:border-black'
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ type MappingDraft = {
|
|||||||
index: number | null
|
index: number | null
|
||||||
description: string
|
description: string
|
||||||
host_port: string
|
host_port: string
|
||||||
|
host_ip: string
|
||||||
container_port: string
|
container_port: string
|
||||||
protocol: string
|
protocol: string
|
||||||
}
|
}
|
||||||
@@ -101,6 +102,7 @@ const emptyDraft: MappingDraft = {
|
|||||||
index: null,
|
index: null,
|
||||||
description: '',
|
description: '',
|
||||||
host_port: '',
|
host_port: '',
|
||||||
|
host_ip: '',
|
||||||
container_port: '',
|
container_port: '',
|
||||||
protocol: 'all',
|
protocol: 'all',
|
||||||
}
|
}
|
||||||
@@ -526,6 +528,7 @@ export default function ContainerDetail() {
|
|||||||
index,
|
index,
|
||||||
description: pm.description,
|
description: pm.description,
|
||||||
host_port: String(pm.host_port),
|
host_port: String(pm.host_port),
|
||||||
|
host_ip: pm.host_ip || '',
|
||||||
container_port: String(pm.container_port),
|
container_port: String(pm.container_port),
|
||||||
protocol: pm.protocol || 'all',
|
protocol: pm.protocol || 'all',
|
||||||
})
|
})
|
||||||
@@ -538,7 +541,11 @@ export default function ContainerDetail() {
|
|||||||
if (!(await ensureSubUserCanOperate())) return false
|
if (!(await ensureSubUserCanOperate())) return false
|
||||||
if (draft.index === null && container) {
|
if (draft.index === null && container) {
|
||||||
const currentCount = container.port_mappings?.length || 0
|
const currentCount = container.port_mappings?.length || 0
|
||||||
const limit = container.port_mapping_limit || Math.max(currentCount, 2)
|
const limit = Math.max(container.port_mapping_limit || 0, currentCount)
|
||||||
|
if (limit <= 0) {
|
||||||
|
dialog.alert('未分配 IPv4 NAT', '该容器未分配 IPv4 NAT 端口配额。')
|
||||||
|
return false
|
||||||
|
}
|
||||||
if (currentCount >= limit) {
|
if (currentCount >= limit) {
|
||||||
dialog.alert('端口配额已满', '已达到管理员分配的 NAT 端口配额。')
|
dialog.alert('端口配额已满', '已达到管理员分配的 NAT 端口配额。')
|
||||||
return false
|
return false
|
||||||
@@ -563,6 +570,7 @@ export default function ContainerDetail() {
|
|||||||
const payload: PortMapping = {
|
const payload: PortMapping = {
|
||||||
container_port: containerPort,
|
container_port: containerPort,
|
||||||
host_port: hostPortVal,
|
host_port: hostPortVal,
|
||||||
|
host_ip: isSubUser ? undefined : (draft.host_ip || undefined),
|
||||||
protocol: protocolVal,
|
protocol: protocolVal,
|
||||||
description: draft.description.trim() || `Port-${containerPort}`,
|
description: draft.description.trim() || `Port-${containerPort}`,
|
||||||
}
|
}
|
||||||
@@ -737,10 +745,17 @@ export default function ContainerDetail() {
|
|||||||
const isPolicyBlocked = !!container.policy_blocked
|
const isPolicyBlocked = !!container.policy_blocked
|
||||||
const isSubUserPolicyBlocked = isSubUser && isPolicyBlocked
|
const isSubUserPolicyBlocked = isSubUser && isPolicyBlocked
|
||||||
const policyBlockedText = container.policy_blocked_reason || '虚拟机被策略临时封禁'
|
const policyBlockedText = container.policy_blocked_reason || '虚拟机被策略临时封禁'
|
||||||
const publicHost = hostInfo?.network.public_ipv4 || PUBLIC_HOST
|
const publicIPv4s = container.public_ipv4s || []
|
||||||
|
const assignedIPv4List = publicIPv4s.map((item) => item.address).filter(Boolean)
|
||||||
|
const publicHost = assignedIPv4List[0] || hostInfo?.network.public_ipv4 || PUBLIC_HOST
|
||||||
|
const ipv6List = (container.ipv6_addresses || [])
|
||||||
|
.map((item) => item.address)
|
||||||
|
.filter(Boolean)
|
||||||
|
if (ipv6List.length === 0 && container.ipv6) ipv6List.push(container.ipv6)
|
||||||
const maxVCPU = hostInfo?.cpu.cores || 64
|
const maxVCPU = hostInfo?.cpu.cores || 64
|
||||||
const maxRAMMB = hostInfo?.ram.total_mb ? Number(hostInfo.ram.total_mb) : undefined
|
const maxRAMMB = hostInfo?.ram.total_mb ? Number(hostInfo.ram.total_mb) : undefined
|
||||||
const sshCommand = `ssh -p ${container.ssh_port} root@${publicHost}`
|
const publicEndpoint = container.ssh_port > 0 ? `${publicHost}:${container.ssh_port}` : '-'
|
||||||
|
const sshCommand = container.ssh_port > 0 ? `ssh -p ${container.ssh_port} root@${publicHost}` : ''
|
||||||
const editingSSH = draft.index !== null && !!container.port_mappings?.[draft.index] && (
|
const editingSSH = draft.index !== null && !!container.port_mappings?.[draft.index] && (
|
||||||
container.port_mappings[draft.index].description === 'SSH' || container.port_mappings[draft.index].container_port === 22 ||
|
container.port_mappings[draft.index].description === 'SSH' || container.port_mappings[draft.index].container_port === 22 ||
|
||||||
container.port_mappings[draft.index].description === 'RDP' || container.port_mappings[draft.index].container_port === 3389
|
container.port_mappings[draft.index].description === 'RDP' || container.port_mappings[draft.index].container_port === 3389
|
||||||
@@ -758,8 +773,9 @@ export default function ContainerDetail() {
|
|||||||
const netPct = Math.min(((usage?.network_rx_bps || 0) + (usage?.network_tx_bps || 0)) / (container.network_bw_mbps > 0 ? container.network_bw_mbps * 125000 : 125000000) * 100, 100)
|
const netPct = Math.min(((usage?.network_rx_bps || 0) + (usage?.network_tx_bps || 0)) / (container.network_bw_mbps > 0 ? container.network_bw_mbps * 125000 : 125000000) * 100, 100)
|
||||||
const diskIOBps = (usage?.disk_read_bps || 0) + (usage?.disk_write_bps || 0)
|
const diskIOBps = (usage?.disk_read_bps || 0) + (usage?.disk_write_bps || 0)
|
||||||
const mappingCount = container.port_mappings?.length || 0
|
const mappingCount = container.port_mappings?.length || 0
|
||||||
const mappingLimit = container.port_mapping_limit || Math.max(mappingCount, 2)
|
const mappingLimit = Math.max(container.port_mapping_limit || 0, mappingCount)
|
||||||
const canAddMapping = isSubUser ? mappingCount < mappingLimit && !isSubUserPolicyBlocked : true
|
const hasNATQuota = mappingLimit > 0
|
||||||
|
const canAddMapping = hasNATQuota && mappingCount < mappingLimit && !isSubUserPolicyBlocked
|
||||||
const managementUrl = subUser?.access_code
|
const managementUrl = subUser?.access_code
|
||||||
? `${window.location.origin}/login?code=${encodeURIComponent(subUser.access_code)}`
|
? `${window.location.origin}/login?code=${encodeURIComponent(subUser.access_code)}`
|
||||||
: ''
|
: ''
|
||||||
@@ -828,8 +844,8 @@ export default function ContainerDetail() {
|
|||||||
<InfoTag color="blue">系统 {container.template}</InfoTag>
|
<InfoTag color="blue">系统 {container.template}</InfoTag>
|
||||||
<InfoTag color="slate">类型 {(container.virtualization || 'lxc').toUpperCase()}</InfoTag>
|
<InfoTag color="slate">类型 {(container.virtualization || 'lxc').toUpperCase()}</InfoTag>
|
||||||
<InfoTag color="emerald">内网 {container.ip || '-'}</InfoTag>
|
<InfoTag color="emerald">内网 {container.ip || '-'}</InfoTag>
|
||||||
<InfoTag color="amber">NAT {mappingCount} 条</InfoTag>
|
<InfoTag color="amber">IPv4 NAT {hasNATQuota ? `${mappingCount} 条` : '未分配'}</InfoTag>
|
||||||
<InfoTag color="violet">{isWindows ? 'RDP' : 'SSH'} {publicHost}:{container.ssh_port}</InfoTag>
|
<InfoTag color="violet">{isWindows ? 'RDP' : 'SSH'} {publicEndpoint}</InfoTag>
|
||||||
{isPolicyBlocked && <InfoTag color="red">策略封禁</InfoTag>}
|
{isPolicyBlocked && <InfoTag color="red">策略封禁</InfoTag>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -874,7 +890,7 @@ export default function ContainerDetail() {
|
|||||||
<>
|
<>
|
||||||
<ActionButton disabled={isSubUserPolicyBlocked} onClick={() => setShowNat(true)}>
|
<ActionButton disabled={isSubUserPolicyBlocked} onClick={() => setShowNat(true)}>
|
||||||
<Settings className="w-3.5 h-3.5" />
|
<Settings className="w-3.5 h-3.5" />
|
||||||
NAT 管理
|
IPv4 NAT 管理
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
</>
|
</>
|
||||||
<ActionButton onClick={() => setShowSnapshots(true)} disabled={!!taskStatus || !!snapshotBusy || isSubUserPolicyBlocked}>
|
<ActionButton onClick={() => setShowSnapshots(true)} disabled={!!taskStatus || !!snapshotBusy || isSubUserPolicyBlocked}>
|
||||||
@@ -926,7 +942,7 @@ export default function ContainerDetail() {
|
|||||||
</div>
|
</div>
|
||||||
) : isWindows ? (
|
) : isWindows ? (
|
||||||
<>
|
<>
|
||||||
<PlainRow label="RDP 地址" value={`${publicHost}:${container.ssh_port}`} mono />
|
<PlainRow label="RDP 地址" value={publicEndpoint} mono />
|
||||||
<PlainRow label="用户名" value="Administrator" mono />
|
<PlainRow label="用户名" value="Administrator" mono />
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<span className="text-gray-500">管理员密码</span>
|
<span className="text-gray-500">管理员密码</span>
|
||||||
@@ -951,7 +967,7 @@ export default function ContainerDetail() {
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<PlainRow label="SSH 地址" value={`${publicHost}:${container.ssh_port}`} mono copyValue={sshCommand} onCopy={copyText} />
|
<PlainRow label="SSH 地址" value={publicEndpoint} mono copyValue={sshCommand} onCopy={copyText} />
|
||||||
<PlainRow label="用户名" value="root" mono />
|
<PlainRow label="用户名" value="root" mono />
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<span className="text-gray-500">SSH 密码</span>
|
<span className="text-gray-500">SSH 密码</span>
|
||||||
@@ -993,8 +1009,9 @@ 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="IPv6" value={container.ipv6 || '-'} mono copyValue={container.ipv6} onCopy={copyText}>
|
<PlainRow label="Public IPv4" value={assignedIPv4List.length ? assignedIPv4List.join(', ') : '-'} mono copyValue={assignedIPv4List[0]} onCopy={copyText} />
|
||||||
{!isSubUser && !container.ipv6 && (
|
<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">
|
<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
|
Assign
|
||||||
</button>
|
</button>
|
||||||
@@ -1386,7 +1403,7 @@ export default function ContainerDetail() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{showNat && (
|
{showNat && (
|
||||||
<Modal title="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 && (
|
||||||
<button onClick={openAddMapping} className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-black text-white rounded-md text-xs hover:bg-gray-800">
|
<button onClick={openAddMapping} className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-black text-white rounded-md text-xs hover:bg-gray-800">
|
||||||
<Plus className="w-3.5 h-3.5" />添加映射
|
<Plus className="w-3.5 h-3.5" />添加映射
|
||||||
@@ -1396,10 +1413,14 @@ export default function ContainerDetail() {
|
|||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<div className="text-xs text-gray-500">
|
<div className="text-xs text-gray-500">
|
||||||
端口配额:<span className="font-mono text-gray-800">{mappingCount}/{mappingLimit}</span>
|
{hasNATQuota ? (
|
||||||
|
<>端口配额:<span className="font-mono text-gray-800">{mappingCount}/{mappingLimit}</span></>
|
||||||
|
) : (
|
||||||
|
<span>未分配 IPv4 NAT 端口配额</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{!isSubUser && !canAddMapping && (
|
{!isSubUser && hasNATQuota && !canAddMapping && (
|
||||||
<div className="text-xs text-amber-600">已达到管理员分配的 NAT 端口配额</div>
|
<div className="text-xs text-amber-600">已达到管理员分配的 IPv4 NAT 端口配额</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<MappingTable mappings={container.port_mappings || []} publicHost={publicHost} onEdit={openEditMapping} onDelete={isSubUser ? () => {} : removeMapping} isSubUser={isSubUser} />
|
<MappingTable mappings={container.port_mappings || []} publicHost={publicHost} onEdit={openEditMapping} onDelete={isSubUser ? () => {} : removeMapping} isSubUser={isSubUser} />
|
||||||
@@ -1419,6 +1440,7 @@ export default function ContainerDetail() {
|
|||||||
canAddMapping={canAddMapping}
|
canAddMapping={canAddMapping}
|
||||||
saving={savingMapping}
|
saving={savingMapping}
|
||||||
containerIdentifier={containerIdentifier}
|
containerIdentifier={containerIdentifier}
|
||||||
|
publicIPv4s={publicIPv4s}
|
||||||
onCancel={() => { setShowMappingEditor(false); setDraft(emptyDraft) }}
|
onCancel={() => { setShowMappingEditor(false); setDraft(emptyDraft) }}
|
||||||
onSubmit={async () => {
|
onSubmit={async () => {
|
||||||
if (await submitMapping()) {
|
if (await submitMapping()) {
|
||||||
@@ -1739,6 +1761,7 @@ function MappingEditor({
|
|||||||
canAddMapping,
|
canAddMapping,
|
||||||
saving,
|
saving,
|
||||||
containerIdentifier,
|
containerIdentifier,
|
||||||
|
publicIPv4s,
|
||||||
onCancel,
|
onCancel,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
}: {
|
}: {
|
||||||
@@ -1748,6 +1771,7 @@ function MappingEditor({
|
|||||||
canAddMapping: boolean
|
canAddMapping: boolean
|
||||||
saving: boolean
|
saving: boolean
|
||||||
containerIdentifier: string
|
containerIdentifier: string
|
||||||
|
publicIPv4s: { address: string; interface?: string }[]
|
||||||
onCancel: () => void
|
onCancel: () => void
|
||||||
onSubmit: () => void
|
onSubmit: () => void
|
||||||
}) {
|
}) {
|
||||||
@@ -1757,7 +1781,8 @@ function MappingEditor({
|
|||||||
|
|
||||||
const fillRandomPort = async () => {
|
const fillRandomPort = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await api.get<APIResponse<{ port: number }>>(`/containers/${containerIdentifier}/random-port`)
|
const params = draft.host_ip ? { host_ip: draft.host_ip } : undefined
|
||||||
|
const res = await api.get<APIResponse<{ port: number }>>(`/containers/${containerIdentifier}/random-port`, { params })
|
||||||
const port = res.data.data?.port || 0
|
const port = res.data.data?.port || 0
|
||||||
if (port > 0) updateDraft({ host_port: String(port) })
|
if (port > 0) updateDraft({ host_port: String(port) })
|
||||||
} catch {
|
} catch {
|
||||||
@@ -1815,6 +1840,21 @@ function MappingEditor({
|
|||||||
)}
|
)}
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Host IPv4">
|
||||||
|
{isSubUser ? (
|
||||||
|
<input value={draft.host_ip || 'All IPv4'} disabled className={disabledInputClass} />
|
||||||
|
) : (
|
||||||
|
<select value={draft.host_ip} onChange={(e) => updateDraft({ host_ip: e.target.value })} className={inputClass}>
|
||||||
|
<option value="">All assigned IPv4</option>
|
||||||
|
{publicIPv4s.map((ip) => (
|
||||||
|
<option key={`${ip.interface}-${ip.address}`} value={ip.address}>
|
||||||
|
{ip.address}{ip.interface ? ` (${ip.interface})` : ''}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
|
||||||
<Field label="内部端口">
|
<Field label="内部端口">
|
||||||
<input
|
<input
|
||||||
value={draft.container_port}
|
value={draft.container_port}
|
||||||
@@ -1854,6 +1894,7 @@ function MappingTable({ mappings, publicHost, onEdit, onDelete, compact = false,
|
|||||||
<tr>
|
<tr>
|
||||||
<TableHead>名称</TableHead>
|
<TableHead>名称</TableHead>
|
||||||
<TableHead>协议</TableHead>
|
<TableHead>协议</TableHead>
|
||||||
|
<TableHead>Host IPv4</TableHead>
|
||||||
<TableHead>外部端口</TableHead>
|
<TableHead>外部端口</TableHead>
|
||||||
<TableHead>内部端口</TableHead>
|
<TableHead>内部端口</TableHead>
|
||||||
{!compact && <th className="text-right px-3 py-2 text-xs font-medium text-gray-500">操作</th>}
|
{!compact && <th className="text-right px-3 py-2 text-xs font-medium text-gray-500">操作</th>}
|
||||||
@@ -1869,7 +1910,8 @@ function MappingTable({ mappings, publicHost, onEdit, onDelete, compact = false,
|
|||||||
{isSSH && <span className="ml-2 px-1.5 py-0.5 rounded bg-emerald-50 text-emerald-700 text-xs">默认</span>}
|
{isSSH && <span className="ml-2 px-1.5 py-0.5 rounded bg-emerald-50 text-emerald-700 text-xs">默认</span>}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 text-xs text-gray-500">{pm.protocol.toUpperCase()}</td>
|
<td className="px-3 py-2 text-xs text-gray-500">{pm.protocol.toUpperCase()}</td>
|
||||||
<td className="px-3 py-2 font-mono text-xs text-gray-800">{publicHost}:{pm.host_port}</td>
|
<td className="px-3 py-2 font-mono text-xs text-gray-800">{pm.host_ip || publicHost || 'All IPv4'}</td>
|
||||||
|
<td className="px-3 py-2 font-mono text-xs text-gray-800">{pm.host_port}</td>
|
||||||
<td className="px-3 py-2 font-mono text-xs text-gray-800">{pm.container_port}</td>
|
<td className="px-3 py-2 font-mono text-xs text-gray-800">{pm.container_port}</td>
|
||||||
{!compact && (
|
{!compact && (
|
||||||
<td className="px-3 py-2">
|
<td className="px-3 py-2">
|
||||||
|
|||||||
@@ -704,14 +704,16 @@ function toPlaceholder(cfg: CreateContainerRequest): DisplayContainer {
|
|||||||
io_speed_mbps: cfg.io_speed_mbps,
|
io_speed_mbps: cfg.io_speed_mbps,
|
||||||
status: 'creating',
|
status: 'creating',
|
||||||
ip: '',
|
ip: '',
|
||||||
|
public_ipv4s: [],
|
||||||
ipv6: '',
|
ipv6: '',
|
||||||
ipv6_prefix_len: 0,
|
ipv6_prefix_len: 0,
|
||||||
ipv6_interface: '',
|
ipv6_interface: '',
|
||||||
|
ipv6_addresses: [],
|
||||||
vnc_port: 0,
|
vnc_port: 0,
|
||||||
ssh_port: 0,
|
ssh_port: 0,
|
||||||
ssh_password: '',
|
ssh_password: '',
|
||||||
port_mappings: [],
|
port_mappings: [],
|
||||||
port_mapping_limit: 2,
|
port_mapping_limit: cfg.assign_nat === false ? 0 : (cfg.port_mapping_count || 0),
|
||||||
snapshot_limit: cfg.snapshot_limit || 3,
|
snapshot_limit: cfg.snapshot_limit || 3,
|
||||||
created_at: '',
|
created_at: '',
|
||||||
expires_at: cfg.expires_at,
|
expires_at: cfg.expires_at,
|
||||||
|
|||||||
@@ -9,8 +9,12 @@ import {
|
|||||||
XCircle,
|
XCircle,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { getHostReport, HostProbeReport } from '../services/api'
|
import { getHostReport, HostProbeReport } from '../services/api'
|
||||||
|
import { useLanguage, type Language } from '../contexts/LanguageContext'
|
||||||
|
import { translateText } from '../utils/i18n'
|
||||||
|
|
||||||
export default function HostReport() {
|
export default function HostReport() {
|
||||||
|
const { language } = useLanguage()
|
||||||
|
const text = hostReportText[language]
|
||||||
const [report, setReport] = useState<HostProbeReport | null>(null)
|
const [report, setReport] = useState<HostProbeReport | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
@@ -31,61 +35,61 @@ export default function HostReport() {
|
|||||||
}, [fetchReport])
|
}, [fetchReport])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6" data-no-translate>
|
||||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-black">宿主机信息</h1>
|
<h1 className="text-2xl font-bold text-black">{text.title}</h1>
|
||||||
<p className="mt-1 text-sm text-gray-500">硬件、网络、磁盘健康与运行环境探测报告</p>
|
<p className="mt-1 text-sm text-gray-500">{text.subtitle}</p>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={fetchReport} disabled={loading} className="inline-flex items-center gap-1.5 rounded-md border border-gray-200 px-3 py-2 text-sm text-gray-600 hover:bg-gray-50 disabled:opacity-50">
|
<button onClick={fetchReport} disabled={loading} className="inline-flex items-center gap-1.5 rounded-md border border-gray-200 px-3 py-2 text-sm text-gray-600 hover:bg-gray-50 disabled:opacity-50">
|
||||||
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
|
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
|
||||||
刷新
|
{text.refresh}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading && !report ? (
|
{loading && !report ? (
|
||||||
<div className="rounded-lg border border-gray-200 bg-white py-14 text-center text-sm text-gray-400">正在探测宿主机环境...</div>
|
<div className="rounded-lg border border-gray-200 bg-white py-14 text-center text-sm text-gray-400">{text.loading}</div>
|
||||||
) : !report ? (
|
) : !report ? (
|
||||||
<div className="rounded-lg border border-gray-200 bg-white py-14 text-center text-sm text-gray-400">暂未获取到宿主机信息</div>
|
<div className="rounded-lg border border-gray-200 bg-white py-14 text-center text-sm text-gray-400">{text.emptyReport}</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||||
<ProbeMetric icon={<Cpu className="h-4 w-4" />} label="CPU" value={report.cpu.model || 'Unknown'} sub={`${report.cpu.cores} 核 / ${report.cpu.threads} 线程`} />
|
<ProbeMetric icon={<Cpu className="h-4 w-4" />} label="CPU" value={report.cpu.model || 'Unknown'} sub={formatCPUThreads(report.cpu.cores, report.cpu.threads, language)} />
|
||||||
<ProbeMetric icon={<MemoryStick className="h-4 w-4" />} label="RAM" value={formatMB(report.memory.total_mb)} sub={`${formatMB(report.memory.used_mb)} 已用`} />
|
<ProbeMetric icon={<MemoryStick className="h-4 w-4" />} label="RAM" value={formatMB(report.memory.total_mb)} sub={formatUsedMemory(report.memory.used_mb, language)} />
|
||||||
<ProbeMetric icon={<HardDrive className="h-4 w-4" />} label="DISK" value={`${report.disks.length} 块硬盘`} sub={report.disks.map(d => d.type).filter(Boolean).join(' / ') || 'Unknown'} />
|
<ProbeMetric icon={<HardDrive className="h-4 w-4" />} label="DISK" value={formatDiskCount(report.disks.length, language)} sub={report.disks.map(d => diskTypeLabel(d, language)).filter(Boolean).join(' / ') || 'Unknown'} />
|
||||||
<ProbeMetric icon={<Activity className="h-4 w-4" />} label="运行状态" value={report.system.uptime_text} sub={`${report.system.process_count} 个进程`} />
|
<ProbeMetric icon={<Activity className="h-4 w-4" />} label={text.runtimeStatus} value={translateDynamic(report.system.uptime_text, language)} sub={formatProcessCount(report.system.process_count, language)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ProbeSection title="系统概览">
|
<ProbeSection title={text.systemOverview}>
|
||||||
<ProbeRows rows={[
|
<ProbeRows rows={[
|
||||||
['主机名', report.hostname],
|
[text.hostname, report.hostname],
|
||||||
['操作系统', report.os],
|
[text.os, report.os],
|
||||||
['内核', report.kernel],
|
[text.kernel, report.kernel],
|
||||||
['生成时间', report.generated_at],
|
[text.generatedAt, report.generated_at],
|
||||||
['CPU 架构', report.cpu.architecture],
|
[text.cpuArch, report.cpu.architecture],
|
||||||
['CPU 虚拟化指令', report.cpu.virtualization ? `支持 (${report.cpu.virtualization_key})` : '未检测到'],
|
[text.cpuVirtualization, report.cpu.virtualization ? `${text.supported} (${report.cpu.virtualization_key})` : text.notDetected],
|
||||||
['CPU 核显', report.cpu.has_integrated_gpu ? '检测到' : '未检测到'],
|
[text.cpuIntegratedGPU, report.cpu.has_integrated_gpu ? text.detected : text.notDetected],
|
||||||
['显卡', report.gpus.length ? `${report.gpus.length} 个` : '未检测到'],
|
[text.gpu, report.gpus.length ? formatItemCount(report.gpus.length, language) : text.notDetected],
|
||||||
['运行能力', runtimeModeLabel(report.runtime.support_mode)],
|
[text.runtimeCapability, runtimeModeLabel(report.runtime.support_mode, language)],
|
||||||
['KVM 嵌套虚拟化', `${report.runtime.nested_virtualization ? '支持' : '未检测到'} (${report.runtime.nested_detail || '-'})`],
|
[text.kvmNested, `${report.runtime.nested_virtualization ? text.supported : text.notDetected} (${translateDynamic(report.runtime.nested_detail || '-', language)})`],
|
||||||
]} />
|
]} />
|
||||||
</ProbeSection>
|
</ProbeSection>
|
||||||
|
|
||||||
<ProbeSection title="公网与路由">
|
<ProbeSection title={text.publicNetwork}>
|
||||||
<ProbeRows rows={[
|
<ProbeRows rows={[
|
||||||
['公网 IPv4', report.public_ipv4.length ? report.public_ipv4.join('\n') : '未检测到'],
|
[text.publicIPv4, report.public_ipv4.length ? report.public_ipv4.join('\n') : text.notDetected],
|
||||||
['IPv4 地址', report.ipv4_addresses?.length ? report.ipv4_addresses.map(formatIPv4Address).join('\n') : '未检测到'],
|
[text.ipv4Address, report.ipv4_addresses?.length ? report.ipv4_addresses.map(formatIPv4Address).join('\n') : text.notDetected],
|
||||||
['IPv4 段', report.ipv4_prefixes?.length ? report.ipv4_prefixes.map(formatIPv4Prefix).join('\n') : '未检测到'],
|
[text.ipv4Prefix, report.ipv4_prefixes?.length ? report.ipv4_prefixes.map(formatIPv4Prefix).join('\n') : text.notDetected],
|
||||||
['IPv6 地址', report.ipv6_addresses.length ? report.ipv6_addresses.map(ip => `${ip.address}/${ip.prefix_len} (${ip.interface})`).join('\n') : '未检测到'],
|
[text.ipv6Address, report.ipv6_addresses.length ? report.ipv6_addresses.map(ip => `${ip.address}/${ip.prefix_len} (${ip.interface})`).join('\n') : text.notDetected],
|
||||||
['IPv6 段', report.ipv6_prefixes?.length ? report.ipv6_prefixes.map(formatIPv6Prefix).join('\n') : '未检测到'],
|
[text.ipv6Prefix, report.ipv6_prefixes?.length ? report.ipv6_prefixes.map(formatIPv6Prefix).join('\n') : text.notDetected],
|
||||||
['网关', report.gateways.length ? report.gateways.map(g => `${g.family}: ${g.gateway || '-'} dev ${g.interface || '-'}`).join('\n') : '未检测到'],
|
[text.gateway, report.gateways.length ? report.gateways.map(g => `${g.family}: ${g.gateway || '-'} dev ${g.interface || '-'}`).join('\n') : text.notDetected],
|
||||||
]} />
|
]} />
|
||||||
</ProbeSection>
|
</ProbeSection>
|
||||||
|
|
||||||
<ProbeTable
|
<ProbeTable
|
||||||
title="内存条"
|
title={text.memoryModules}
|
||||||
empty="未检测到内存条明细,可能缺少 dmidecode 或权限受限"
|
empty={text.noMemoryModules}
|
||||||
headers={['插槽', '容量', '类型', '频率', '厂商', '型号/序列号']}
|
headers={[text.slot, text.capacity, text.type, text.frequency, text.vendor, text.modelSerial]}
|
||||||
rows={(report.memory.modules || []).map(m => [
|
rows={(report.memory.modules || []).map(m => [
|
||||||
m.locator || '-',
|
m.locator || '-',
|
||||||
m.size || '-',
|
m.size || '-',
|
||||||
@@ -97,29 +101,29 @@ export default function HostReport() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<ProbeTable
|
<ProbeTable
|
||||||
title="硬盘与健康"
|
title={text.disksHealth}
|
||||||
empty="未检测到硬盘"
|
empty={text.noDisks}
|
||||||
headers={['设备', '型号', '容量', '类型', '挂载点', '健康', '寿命', '通电', '读取', '写入', '命令数', '擦写']}
|
headers={[text.device, text.model, text.capacity, text.type, text.mountPoint, text.health, text.lifetime, text.powerOn, text.reads, text.writes, text.commands, text.eraseCount]}
|
||||||
rows={report.disks.map(d => [
|
rows={report.disks.map(d => [
|
||||||
`${d.path || d.name}\n${d.serial || ''}`,
|
`${d.path || d.name}\n${d.serial || ''}`,
|
||||||
d.model || '-',
|
d.model || '-',
|
||||||
formatBytes(d.size_bytes),
|
formatBytes(d.size_bytes),
|
||||||
d.type || (d.rotational ? 'HDD' : 'SSD'),
|
diskTypeLabel(d, language),
|
||||||
d.mountpoints?.length ? d.mountpoints.join('\n') : '-',
|
d.mountpoints?.length ? d.mountpoints.join('\n') : '-',
|
||||||
`${diskHealthLabel(d.health)}\n${d.health_detail || ''}`,
|
`${diskHealthLabel(d.health, language)}\n${diskHealthDetail(d, language)}`,
|
||||||
formatLifeUsed(d.smart?.life_used_percent),
|
d.virtual ? text.unsupported : formatLifeUsed(d.smart?.life_used_percent, language),
|
||||||
d.smart?.power_on_hours ? `${d.smart.power_on_hours} 小时\n${formatPowerOnDays(d.smart.power_on_hours)}` : '-',
|
d.virtual ? text.unsupported : (d.smart?.power_on_hours ? `${d.smart.power_on_hours} ${text.hours}\n${formatPowerOnDays(d.smart.power_on_hours, language)}` : '-'),
|
||||||
formatBytes(d.smart?.read_data_bytes || 0),
|
d.virtual ? text.unsupported : formatBytes(d.smart?.read_data_bytes || 0),
|
||||||
formatBytes(d.smart?.written_data_bytes || 0),
|
d.virtual ? text.unsupported : formatBytes(d.smart?.written_data_bytes || 0),
|
||||||
formatCommands(d.smart?.read_commands, d.smart?.write_commands),
|
d.virtual ? text.unsupported : formatCommands(d.smart?.read_commands, d.smart?.write_commands, language),
|
||||||
formatWear(d.smart?.wear_leveling_count, d.smart?.erase_count, d.smart?.power_cycle_count),
|
d.virtual ? text.unsupported : formatWear(d.smart?.wear_leveling_count, d.smart?.erase_count, d.smart?.power_cycle_count, language),
|
||||||
])}
|
])}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ProbeTable
|
<ProbeTable
|
||||||
title="网卡"
|
title={text.networkInterfaces}
|
||||||
empty="未检测到网卡"
|
empty={text.noNetworkInterfaces}
|
||||||
headers={['网卡', '状态', '驱动/速率', 'MAC', 'IPv4', 'IPv6']}
|
headers={[text.nic, text.status, text.driverSpeed, 'MAC', 'IPv4', 'IPv6']}
|
||||||
rows={report.network_interfaces.map(n => [
|
rows={report.network_interfaces.map(n => [
|
||||||
`${n.name}\n${n.model || ''}`,
|
`${n.name}\n${n.model || ''}`,
|
||||||
n.state || '-',
|
n.state || '-',
|
||||||
@@ -131,25 +135,25 @@ export default function HostReport() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<ProbeTable
|
<ProbeTable
|
||||||
title="显卡"
|
title={text.gpus}
|
||||||
empty="未检测到显卡"
|
empty={text.noGPUs}
|
||||||
headers={['名称', '厂商', '类型', '驱动']}
|
headers={[text.name, text.vendor, text.type, text.driver]}
|
||||||
rows={report.gpus.map(g => [g.name, g.vendor || '-', gpuTypeLabel(g.type), g.driver || '-'])}
|
rows={report.gpus.map(g => [g.name, g.vendor || '-', gpuTypeLabel(g.type, language), g.driver || '-'])}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ProbeSection title="环境支持">
|
<ProbeSection title={text.environmentSupport}>
|
||||||
<div className="grid gap-2 md:grid-cols-2">
|
<div className="grid gap-2 md:grid-cols-2">
|
||||||
{report.environment.map(item => (
|
{report.environment.map(item => (
|
||||||
<div key={item.key} className="flex items-start gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2">
|
<div key={item.key} className="flex items-start gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2">
|
||||||
{item.ok ? <CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0 text-green-600" /> : <XCircle className={`mt-0.5 h-4 w-4 shrink-0 ${item.required ? 'text-red-600' : 'text-amber-600'}`} />}
|
{item.ok ? <CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0 text-green-600" /> : <XCircle className={`mt-0.5 h-4 w-4 shrink-0 ${item.required ? 'text-red-600' : 'text-amber-600'}`} />}
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex flex-wrap items-center gap-2 text-xs font-medium text-gray-800">
|
<div className="flex flex-wrap items-center gap-2 text-xs font-medium text-gray-800">
|
||||||
<span>{item.label}</span>
|
<span>{translateDynamic(item.label, language)}</span>
|
||||||
<span className={`rounded px-1.5 py-0.5 text-[10px] ${item.required ? 'bg-gray-100 text-gray-600' : 'bg-blue-50 text-blue-700'}`}>
|
<span className={`rounded px-1.5 py-0.5 text-[10px] ${item.required ? 'bg-gray-100 text-gray-600' : 'bg-blue-50 text-blue-700'}`}>
|
||||||
{item.required ? '必要' : '可选'}
|
{item.required ? text.required : text.optional}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 break-all font-mono text-[11px] text-gray-500">{item.detail || '-'}</div>
|
<div className="mt-1 break-all font-mono text-[11px] text-gray-500">{translateDynamic(item.detail || '-', language)}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -174,6 +178,153 @@ function ProbeMetric({ icon, label, value, sub }: { icon: ReactNode; label: stri
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hostReportText = {
|
||||||
|
zh: {
|
||||||
|
title: '宿主机信息',
|
||||||
|
subtitle: '硬件、网络、磁盘健康与运行环境探测报告',
|
||||||
|
refresh: '刷新',
|
||||||
|
loading: '正在探测宿主机环境...',
|
||||||
|
emptyReport: '暂未获取到宿主机信息',
|
||||||
|
runtimeStatus: '运行状态',
|
||||||
|
systemOverview: '系统概览',
|
||||||
|
hostname: '主机名',
|
||||||
|
os: '操作系统',
|
||||||
|
kernel: '内核',
|
||||||
|
generatedAt: '生成时间',
|
||||||
|
cpuArch: 'CPU 架构',
|
||||||
|
cpuVirtualization: 'CPU 虚拟化指令',
|
||||||
|
cpuIntegratedGPU: 'CPU 核显',
|
||||||
|
gpu: '显卡',
|
||||||
|
runtimeCapability: '运行能力',
|
||||||
|
kvmNested: 'KVM 嵌套虚拟化',
|
||||||
|
supported: '支持',
|
||||||
|
detected: '检测到',
|
||||||
|
notDetected: '未检测到',
|
||||||
|
publicNetwork: '公网与路由',
|
||||||
|
publicIPv4: '公网 IPv4',
|
||||||
|
ipv4Address: 'IPv4 地址',
|
||||||
|
ipv4Prefix: 'IPv4 段',
|
||||||
|
ipv6Address: 'IPv6 地址',
|
||||||
|
ipv6Prefix: 'IPv6 段',
|
||||||
|
gateway: '网关',
|
||||||
|
memoryModules: '内存条',
|
||||||
|
noMemoryModules: '未检测到内存条明细,可能缺少 dmidecode 或权限受限',
|
||||||
|
slot: '插槽',
|
||||||
|
capacity: '容量',
|
||||||
|
type: '类型',
|
||||||
|
frequency: '频率',
|
||||||
|
vendor: '厂商',
|
||||||
|
modelSerial: '型号/序列号',
|
||||||
|
disksHealth: '硬盘与健康',
|
||||||
|
noDisks: '未检测到硬盘',
|
||||||
|
device: '设备',
|
||||||
|
model: '型号',
|
||||||
|
mountPoint: '挂载点',
|
||||||
|
health: '健康',
|
||||||
|
lifetime: '寿命',
|
||||||
|
powerOn: '通电',
|
||||||
|
reads: '读取',
|
||||||
|
writes: '写入',
|
||||||
|
commands: '命令数',
|
||||||
|
eraseCount: '擦写',
|
||||||
|
virtualDisk: '虚拟磁盘',
|
||||||
|
virtualDiskDetail: '虚拟磁盘,真实 SMART/寿命/通电数据需在物理宿主机查看',
|
||||||
|
unsupported: '不支持',
|
||||||
|
hours: '小时',
|
||||||
|
used: '已用',
|
||||||
|
remaining: '剩余',
|
||||||
|
read: '读',
|
||||||
|
write: '写',
|
||||||
|
wear: '磨损',
|
||||||
|
erase: '擦写',
|
||||||
|
powerCycles: '启停',
|
||||||
|
networkInterfaces: '网卡',
|
||||||
|
noNetworkInterfaces: '未检测到网卡',
|
||||||
|
nic: '网卡',
|
||||||
|
status: '状态',
|
||||||
|
driverSpeed: '驱动/速率',
|
||||||
|
gpus: '显卡',
|
||||||
|
noGPUs: '未检测到显卡',
|
||||||
|
name: '名称',
|
||||||
|
driver: '驱动',
|
||||||
|
environmentSupport: '环境支持',
|
||||||
|
required: '必要',
|
||||||
|
optional: '可选',
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
title: 'Host Info',
|
||||||
|
subtitle: 'Hardware, network, disk health, and runtime environment report',
|
||||||
|
refresh: 'Refresh',
|
||||||
|
loading: 'Probing host environment...',
|
||||||
|
emptyReport: 'No host information available',
|
||||||
|
runtimeStatus: 'Runtime Status',
|
||||||
|
systemOverview: 'System Overview',
|
||||||
|
hostname: 'Hostname',
|
||||||
|
os: 'Operating System',
|
||||||
|
kernel: 'Kernel',
|
||||||
|
generatedAt: 'Generated At',
|
||||||
|
cpuArch: 'CPU Architecture',
|
||||||
|
cpuVirtualization: 'CPU Virtualization',
|
||||||
|
cpuIntegratedGPU: 'CPU Integrated GPU',
|
||||||
|
gpu: 'GPU',
|
||||||
|
runtimeCapability: 'Runtime Capability',
|
||||||
|
kvmNested: 'KVM Nested Virtualization',
|
||||||
|
supported: 'Supported',
|
||||||
|
detected: 'Detected',
|
||||||
|
notDetected: 'Not detected',
|
||||||
|
publicNetwork: 'Public Network & Routing',
|
||||||
|
publicIPv4: 'Public IPv4',
|
||||||
|
ipv4Address: 'IPv4 Addresses',
|
||||||
|
ipv4Prefix: 'IPv4 Prefixes',
|
||||||
|
ipv6Address: 'IPv6 Addresses',
|
||||||
|
ipv6Prefix: 'IPv6 Prefixes',
|
||||||
|
gateway: 'Gateway',
|
||||||
|
memoryModules: 'Memory Modules',
|
||||||
|
noMemoryModules: 'No memory module details detected. dmidecode may be missing or permissions may be limited.',
|
||||||
|
slot: 'Slot',
|
||||||
|
capacity: 'Capacity',
|
||||||
|
type: 'Type',
|
||||||
|
frequency: 'Frequency',
|
||||||
|
vendor: 'Vendor',
|
||||||
|
modelSerial: 'Model / Serial',
|
||||||
|
disksHealth: 'Disks & Health',
|
||||||
|
noDisks: 'No disks detected',
|
||||||
|
device: 'Device',
|
||||||
|
model: 'Model',
|
||||||
|
mountPoint: 'Mount Point',
|
||||||
|
health: 'Health',
|
||||||
|
lifetime: 'Lifetime',
|
||||||
|
powerOn: 'Power-on',
|
||||||
|
reads: 'Reads',
|
||||||
|
writes: 'Writes',
|
||||||
|
commands: 'Commands',
|
||||||
|
eraseCount: 'Erase Count',
|
||||||
|
virtualDisk: 'Virtual Disk',
|
||||||
|
virtualDiskDetail: 'Virtual disk. Real SMART, lifetime, and power-on data must be checked on the physical host.',
|
||||||
|
unsupported: 'Unsupported',
|
||||||
|
hours: 'hours',
|
||||||
|
used: 'used',
|
||||||
|
remaining: 'remaining',
|
||||||
|
read: 'Read',
|
||||||
|
write: 'Write',
|
||||||
|
wear: 'Wear',
|
||||||
|
erase: 'Erase',
|
||||||
|
powerCycles: 'Power cycles',
|
||||||
|
networkInterfaces: 'Network Interfaces',
|
||||||
|
noNetworkInterfaces: 'No network interfaces detected',
|
||||||
|
nic: 'NIC',
|
||||||
|
status: 'Status',
|
||||||
|
driverSpeed: 'Driver / Speed',
|
||||||
|
gpus: 'GPUs',
|
||||||
|
noGPUs: 'No GPUs detected',
|
||||||
|
name: 'Name',
|
||||||
|
driver: 'Driver',
|
||||||
|
environmentSupport: 'Environment Support',
|
||||||
|
required: 'Required',
|
||||||
|
optional: 'Optional',
|
||||||
|
},
|
||||||
|
} as const
|
||||||
|
|
||||||
function ProbeSection({ title, children }: { title: string; children: ReactNode }) {
|
function ProbeSection({ title, children }: { title: string; children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<section>
|
<section>
|
||||||
@@ -255,6 +406,26 @@ function formatMB(value: number) {
|
|||||||
return `${value} MB`
|
return `${value} MB`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatCPUThreads(cores: number, threads: number, language: Language) {
|
||||||
|
return language === 'en' ? `${cores} cores / ${threads} threads` : `${cores} 核 / ${threads} 线程`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatUsedMemory(usedMB: number, language: Language) {
|
||||||
|
return language === 'en' ? `${formatMB(usedMB)} used` : `${formatMB(usedMB)} 已用`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDiskCount(count: number, language: Language) {
|
||||||
|
return language === 'en' ? `${count} disk${count === 1 ? '' : 's'}` : `${count} 块硬盘`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatProcessCount(count: number, language: Language) {
|
||||||
|
return language === 'en' ? `${count} process${count === 1 ? '' : 'es'}` : `${count} 个进程`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatItemCount(count: number, language: Language) {
|
||||||
|
return language === 'en' ? `${count} item${count === 1 ? '' : 's'}` : `${count} 个`
|
||||||
|
}
|
||||||
|
|
||||||
function formatBytes(value: number) {
|
function formatBytes(value: number) {
|
||||||
if (!value) return '-'
|
if (!value) return '-'
|
||||||
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']
|
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']
|
||||||
@@ -267,20 +438,24 @@ function formatBytes(value: number) {
|
|||||||
return `${next.toFixed(index === 0 ? 0 : 1)} ${units[index]}`
|
return `${next.toFixed(index === 0 ? 0 : 1)} ${units[index]}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatLifeUsed(value?: number) {
|
function formatLifeUsed(value: number | undefined, language: Language) {
|
||||||
if (value === undefined || value === null) return '-'
|
if (value === undefined || value === null) return '-'
|
||||||
return `${value}% 已用\n${Math.max(0, 100 - value)}% 剩余`
|
const text = hostReportText[language]
|
||||||
|
return `${value}% ${text.used}\n${Math.max(0, 100 - value)}% ${text.remaining}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatPowerOnDays(hours: number) {
|
function formatPowerOnDays(hours: number, language: Language) {
|
||||||
const days = Math.floor(hours / 24)
|
const days = Math.floor(hours / 24)
|
||||||
const rest = hours % 24
|
const rest = hours % 24
|
||||||
return days > 0 ? `${days} 天 ${rest} 小时` : `${hours} 小时`
|
return language === 'en'
|
||||||
|
? (days > 0 ? `${days} days ${rest} hours` : `${hours} hours`)
|
||||||
|
: (days > 0 ? `${days} 天 ${rest} 小时` : `${hours} 小时`)
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatCommands(read?: number, write?: number) {
|
function formatCommands(read: number | undefined, write: number | undefined, language: Language) {
|
||||||
if (!read && !write) return '-'
|
if (!read && !write) return '-'
|
||||||
return `读 ${formatCount(read || 0)}\n写 ${formatCount(write || 0)}`
|
const text = hostReportText[language]
|
||||||
|
return `${text.read} ${formatCount(read || 0)}\n${text.write} ${formatCount(write || 0)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatCount(value: number) {
|
function formatCount(value: number) {
|
||||||
@@ -291,38 +466,62 @@ function formatCount(value: number) {
|
|||||||
return `${value}`
|
return `${value}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatWear(wear?: string, erase?: string, powerCycles?: number) {
|
function formatWear(wear: string | undefined, erase: string | undefined, powerCycles: number | undefined, language: Language) {
|
||||||
|
const text = hostReportText[language]
|
||||||
const rows: string[] = []
|
const rows: string[] = []
|
||||||
if (wear) rows.push(`磨损 ${wear}`)
|
if (wear) rows.push(`${text.wear} ${wear}`)
|
||||||
if (erase) rows.push(`擦写 ${erase}`)
|
if (erase) rows.push(`${text.erase} ${erase}`)
|
||||||
if (powerCycles) rows.push(`启停 ${powerCycles}`)
|
if (powerCycles) rows.push(`${text.powerCycles} ${powerCycles}`)
|
||||||
return rows.length ? rows.join('\n') : '-'
|
return rows.length ? rows.join('\n') : '-'
|
||||||
}
|
}
|
||||||
|
|
||||||
function runtimeModeLabel(value: string) {
|
function runtimeModeLabel(value: string, language: Language) {
|
||||||
switch (value) {
|
switch (value) {
|
||||||
case 'kvm_lxc':
|
case 'kvm_lxc':
|
||||||
return '支持 KVM + LXC'
|
return language === 'en' ? 'KVM + LXC supported' : '支持 KVM + LXC'
|
||||||
case 'lxc_only':
|
case 'lxc_only':
|
||||||
return '仅支持 LXC'
|
return language === 'en' ? 'LXC only' : '仅支持 LXC'
|
||||||
default:
|
default:
|
||||||
return '未满足运行环境'
|
return language === 'en' ? 'Runtime requirements not met' : '未满足运行环境'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function diskHealthLabel(value: string) {
|
function diskHealthLabel(value: string, language: Language) {
|
||||||
|
const text = hostReportText[language]
|
||||||
switch (value) {
|
switch (value) {
|
||||||
case 'ok':
|
case 'ok':
|
||||||
return '健康'
|
return language === 'en' ? 'Healthy' : '健康'
|
||||||
case 'failed':
|
case 'failed':
|
||||||
return '异常'
|
return language === 'en' ? 'Failed' : '异常'
|
||||||
|
case 'virtual':
|
||||||
|
return text.virtualDisk
|
||||||
default:
|
default:
|
||||||
return '未知'
|
return language === 'en' ? 'Unknown' : '未知'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function gpuTypeLabel(value: string) {
|
function diskHealthDetail(d: { virtual?: boolean; health_detail?: string }, language: Language) {
|
||||||
if (value === 'integrated') return '核显'
|
if (d.virtual) return hostReportText[language].virtualDiskDetail
|
||||||
if (value === 'discrete') return '独显'
|
return translateDynamic(d.health_detail || '', language)
|
||||||
|
}
|
||||||
|
|
||||||
|
function diskTypeLabel(d: { type?: string; rotational?: boolean; virtual?: boolean }, language: Language) {
|
||||||
|
if (d.virtual || d.type === 'Virtual') return hostReportText[language].virtualDisk
|
||||||
|
return d.type || (d.rotational ? 'HDD' : 'SSD')
|
||||||
|
}
|
||||||
|
|
||||||
|
function gpuTypeLabel(value: string, language: Language) {
|
||||||
|
if (value === 'integrated') return language === 'en' ? 'Integrated' : '核显'
|
||||||
|
if (value === 'discrete') return language === 'en' ? 'Discrete' : '独显'
|
||||||
return value || '-'
|
return value || '-'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function translateDynamic(value: string, language: Language) {
|
||||||
|
if (language !== 'en' || !value) return value
|
||||||
|
return translateText(value)
|
||||||
|
.replace(/寿命已用\s*(\d+)%/g, 'Lifetime used $1%')
|
||||||
|
.replace(/通电\s*(\d+)h/g, 'Power-on $1h')
|
||||||
|
.replace(/写入\s*([^|]+)/g, 'Written $1')
|
||||||
|
.replace(/读取\s*([^|]+)/g, 'Read $1')
|
||||||
|
.replace(/介质错误\s*(\d+)/g, 'Media errors $1')
|
||||||
|
}
|
||||||
|
|||||||
+607
-181
@@ -1,13 +1,29 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'
|
||||||
import { RefreshCw, Search, Server, X } from 'lucide-react'
|
import { Globe2, Network, Pencil, Plus, RefreshCw, Router, Save, Search, Server, Trash2, X } from 'lucide-react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { getRoutingInfo, RoutingInfo, NAT4Route, IPv6Route } from '../services/api'
|
import { useLanguage, type Language } from '../contexts/LanguageContext'
|
||||||
|
import {
|
||||||
|
getRoutingInfo,
|
||||||
|
updateRoutingIPv4Pool,
|
||||||
|
type IPv4Route,
|
||||||
|
type IPv6Route,
|
||||||
|
type NAT4Route,
|
||||||
|
type PublicIPv4Info,
|
||||||
|
type RoutingInfo,
|
||||||
|
} from '../services/api'
|
||||||
|
|
||||||
export default function Routing() {
|
export default function Routing() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const { language } = useLanguage()
|
||||||
|
const text = routingText[language]
|
||||||
const [routing, setRouting] = useState<RoutingInfo | null>(null)
|
const [routing, setRouting] = useState<RoutingInfo | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [refreshing, setRefreshing] = useState(false)
|
const [refreshing, setRefreshing] = useState(false)
|
||||||
|
const [editingIPv4, setEditingIPv4] = useState(false)
|
||||||
|
const [ipv4EditMode, setIPv4EditMode] = useState<'pool' | 'address'>('pool')
|
||||||
|
const [editingIPv4Address, setEditingIPv4Address] = useState('')
|
||||||
|
const [savingIPv4, setSavingIPv4] = useState(false)
|
||||||
|
const [ipv4Draft, setIPv4Draft] = useState<PublicIPv4Info[]>([])
|
||||||
const [nat4Page, setNat4Page] = useState(1)
|
const [nat4Page, setNat4Page] = useState(1)
|
||||||
const [ipv6Page, setIPv6Page] = useState(1)
|
const [ipv6Page, setIPv6Page] = useState(1)
|
||||||
const [nat4Search, setNat4Search] = useState('')
|
const [nat4Search, setNat4Search] = useState('')
|
||||||
@@ -27,36 +43,112 @@ export default function Routing() {
|
|||||||
|
|
||||||
useEffect(() => { fetchData() }, [fetchData])
|
useEffect(() => { fetchData() }, [fetchData])
|
||||||
|
|
||||||
|
const publicIPv4s = routing?.public_ipv4_addresses || []
|
||||||
|
const ipv4Assignments = routing?.ipv4_assignments || []
|
||||||
const nat4Mappings = routing?.nat4_mappings || []
|
const nat4Mappings = routing?.nat4_mappings || []
|
||||||
|
const ipv6Prefixes = routing?.ipv6_prefixes || []
|
||||||
const ipv6Assignments = routing?.ipv6_assignments || []
|
const ipv6Assignments = routing?.ipv6_assignments || []
|
||||||
const ipv6Prefix = routing?.ipv6_prefixes?.[0]?.prefix || '-'
|
const defaultIPv4Interface = routing?.host_public_ipv4?.interface || publicIPv4s[0]?.interface || 'eth0'
|
||||||
|
const defaultIPv4Gateway = routing?.host_public_ipv4?.gateway || publicIPv4s[0]?.gateway || ''
|
||||||
|
const defaultIPv4PrefixLen = routing?.host_public_ipv4?.prefix_len || publicIPv4s[0]?.prefix_len || 32
|
||||||
|
|
||||||
// Filter helpers
|
useEffect(() => {
|
||||||
const matchesNat4Search = (m: NAT4Route, query: string) => {
|
if (!editingIPv4) {
|
||||||
if (!query) return true
|
setIPv4Draft(publicIPv4s.map((ip) => ({ ...ip })))
|
||||||
const q = query.toLowerCase()
|
|
||||||
return (
|
|
||||||
String(m.host_port).includes(q) ||
|
|
||||||
String(m.container_port).includes(q) ||
|
|
||||||
m.container_name.toLowerCase().includes(q) ||
|
|
||||||
m.lxc_name.toLowerCase().includes(q) ||
|
|
||||||
(m.ip || '').toLowerCase().includes(q)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
const matchesIPv6Search = (item: IPv6Route, query: string) => {
|
}, [editingIPv4, publicIPv4s])
|
||||||
if (!query) return true
|
|
||||||
const q = query.toLowerCase()
|
const assignedIPv4 = useMemo(() => {
|
||||||
return (
|
const byAddress = new Map<string, IPv4Route>()
|
||||||
(item.address || '').toLowerCase().includes(q) ||
|
ipv4Assignments.forEach((item) => byAddress.set(item.address, item))
|
||||||
item.container_name.toLowerCase().includes(q) ||
|
return byAddress
|
||||||
item.lxc_name.toLowerCase().includes(q)
|
}, [ipv4Assignments])
|
||||||
)
|
|
||||||
|
const startEditIPv4 = () => {
|
||||||
|
setIPv4Draft(publicIPv4s.map((ip) => ({ ...ip })))
|
||||||
|
setIPv4EditMode('pool')
|
||||||
|
setEditingIPv4Address('')
|
||||||
|
setEditingIPv4(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
const filteredNat4 = useMemo(() => nat4Mappings.filter(m => matchesNat4Search(m, nat4Search)), [nat4Mappings, nat4Search])
|
const startEditIPv4Address = (ip: PublicIPv4Info) => {
|
||||||
const filteredIPv6 = useMemo(() => ipv6Assignments.filter(m => matchesIPv6Search(m, ipv6Search)), [ipv6Assignments, ipv6Search])
|
setIPv4Draft([{ ...ip }])
|
||||||
|
setIPv4EditMode('address')
|
||||||
|
setEditingIPv4Address(ip.address)
|
||||||
|
setEditingIPv4(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeEditIPv4 = () => {
|
||||||
|
setEditingIPv4(false)
|
||||||
|
setIPv4EditMode('pool')
|
||||||
|
setEditingIPv4Address('')
|
||||||
|
setIPv4Draft(publicIPv4s.map((ip) => ({ ...ip })))
|
||||||
|
}
|
||||||
|
|
||||||
|
const addIPv4Row = () => {
|
||||||
|
setIPv4Draft((items) => [
|
||||||
|
...items,
|
||||||
|
{
|
||||||
|
address: '',
|
||||||
|
interface: defaultIPv4Interface,
|
||||||
|
prefix: '',
|
||||||
|
prefix_len: defaultIPv4PrefixLen,
|
||||||
|
subnet_mask: subnetMaskFromPrefixLen(defaultIPv4PrefixLen),
|
||||||
|
gateway: defaultIPv4Gateway,
|
||||||
|
source: 'manual',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateIPv4Draft = (index: number, patch: Partial<PublicIPv4Info>) => {
|
||||||
|
setIPv4Draft((items) => items.map((item, i) => (i === index ? { ...item, ...patch } : item)))
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveIPv4Pool = async () => {
|
||||||
|
setSavingIPv4(true)
|
||||||
|
try {
|
||||||
|
const draftItems = ipv4Draft
|
||||||
|
.map((item) => ({
|
||||||
|
...item,
|
||||||
|
address: (item.address || '').trim(),
|
||||||
|
interface: (item.interface || defaultIPv4Interface).trim(),
|
||||||
|
gateway: (item.gateway || defaultIPv4Gateway).trim(),
|
||||||
|
prefix_len: Number(item.prefix_len || defaultIPv4PrefixLen),
|
||||||
|
}))
|
||||||
|
.filter((item) => item.address)
|
||||||
|
if (draftItems.some((item) => !item.gateway)) {
|
||||||
|
alert(text.ipv4GatewayRequired)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (ipv4EditMode === 'address' && draftItems.length === 0) {
|
||||||
|
alert(text.ipv4AddressRequired)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const items = ipv4EditMode === 'address'
|
||||||
|
? mergeIPv4PoolItem(publicIPv4s, editingIPv4Address, draftItems[0])
|
||||||
|
: draftItems
|
||||||
|
const res = await updateRoutingIPv4Pool(items)
|
||||||
|
setRouting(res.data.data || null)
|
||||||
|
setEditingIPv4(false)
|
||||||
|
} catch (err: any) {
|
||||||
|
alert(err?.response?.data?.message || text.saveIPv4PoolFailed)
|
||||||
|
} finally {
|
||||||
|
setSavingIPv4(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredNat4 = useMemo(() => {
|
||||||
|
const q = nat4Search.toLowerCase().trim()
|
||||||
|
if (!q) return nat4Mappings
|
||||||
|
return nat4Mappings.filter((item) => matchesNat4(item, q))
|
||||||
|
}, [nat4Mappings, nat4Search])
|
||||||
|
|
||||||
|
const filteredIPv6 = useMemo(() => {
|
||||||
|
const q = ipv6Search.toLowerCase().trim()
|
||||||
|
if (!q) return ipv6Assignments
|
||||||
|
return ipv6Assignments.filter((item) => matchesIPv6(item, q))
|
||||||
|
}, [ipv6Assignments, ipv6Search])
|
||||||
|
|
||||||
// Reset page on search change
|
|
||||||
useEffect(() => { setNat4Page(1) }, [nat4Search])
|
useEffect(() => { setNat4Page(1) }, [nat4Search])
|
||||||
useEffect(() => { setIPv6Page(1) }, [ipv6Search])
|
useEffect(() => { setIPv6Page(1) }, [ipv6Search])
|
||||||
|
|
||||||
@@ -75,13 +167,14 @@ export default function Routing() {
|
|||||||
const currentIPv6Page = Math.min(ipv6Page, ipv6TotalPages)
|
const currentIPv6Page = Math.min(ipv6Page, ipv6TotalPages)
|
||||||
const pagedNat4Mappings = filteredNat4.slice((currentNat4Page - 1) * pageSize, currentNat4Page * pageSize)
|
const pagedNat4Mappings = filteredNat4.slice((currentNat4Page - 1) * pageSize, currentNat4Page * pageSize)
|
||||||
const pagedIPv6Assignments = filteredIPv6.slice((currentIPv6Page - 1) * pageSize, currentIPv6Page * pageSize)
|
const pagedIPv6Assignments = filteredIPv6.slice((currentIPv6Page - 1) * pageSize, currentIPv6Page * pageSize)
|
||||||
|
const editingIPv4Assignment = editingIPv4Address ? assignedIPv4.get(editingIPv4Address) : undefined
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-semibold text-black">路由管理</h1>
|
<h1 className="text-xl font-semibold text-black">{text.pageTitle}</h1>
|
||||||
<p className="mt-1 text-sm text-gray-500">宿主机分配给 LXC 的 NAT4 端口和 IPv6 地址</p>
|
<p className="mt-1 text-sm text-gray-500">{text.pageSubtitle}</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => { setRefreshing(true); fetchData() }}
|
onClick={() => { setRefreshing(true); fetchData() }}
|
||||||
@@ -89,154 +182,246 @@ export default function Routing() {
|
|||||||
className="inline-flex items-center gap-2 rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 disabled:opacity-50"
|
className="inline-flex items-center gap-2 rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<RefreshCw className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />
|
<RefreshCw className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />
|
||||||
刷新
|
{text.refresh}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
<CapacityCard
|
<CapacityCard title={text.nat4Ports} icon={<Network className="h-5 w-5" />} remaining={routing?.nat4.remaining || '0'} total={routing?.nat4.total || '0'} used={routing?.nat4.used || 0} label={text.remainingTotal} usedLabel={text.used} />
|
||||||
title="NAT4 端口"
|
<CapacityCard title={text.publicIPv4} icon={<Globe2 className="h-5 w-5" />} remaining={routing?.ipv4.remaining || '0'} total={routing?.ipv4.total || '0'} used={routing?.ipv4.used || 0} label={formatPoolCount(publicIPv4s.length, language)} usedLabel={text.used} />
|
||||||
icon={<Nat4Icon />}
|
<CapacityCard title="IPv6" icon={<Router className="h-5 w-5" />} remaining={formatCapacity(routing?.ipv6.remaining || '0', language)} total={formatCapacity(routing?.ipv6.total || '0', language)} used={routing?.ipv6.used || 0} label={formatDetectedPrefixCount(ipv6Prefixes.length, language)} usedLabel={text.used} />
|
||||||
remaining={routing?.nat4.remaining || '0'}
|
|
||||||
total={routing?.nat4.total || '0'}
|
|
||||||
used={routing?.nat4.used || 0}
|
|
||||||
label="剩余端口 / 端口总数"
|
|
||||||
/>
|
|
||||||
<CapacityCard
|
|
||||||
title="IPv6 地址"
|
|
||||||
icon={<IPv6Icon />}
|
|
||||||
remaining={formatCapacity(routing?.ipv6.remaining || '0')}
|
|
||||||
total={formatCapacity(routing?.ipv6.total || '0')}
|
|
||||||
used={routing?.ipv6.used || 0}
|
|
||||||
label={`剩余地址 / 地址总数 · ${ipv6Prefix}`}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="overflow-hidden rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900">
|
<Panel
|
||||||
<div className="border-b border-gray-200 dark:border-gray-700 px-4 py-3 flex items-center justify-between gap-3">
|
title={text.publicIPv4Pool}
|
||||||
|
subtitle={formatIPv4PoolSubtitle(publicIPv4s.length, ipv4Assignments.length, language)}
|
||||||
|
action={
|
||||||
|
<button onClick={startEditIPv4} className="inline-flex items-center gap-1.5 rounded-md border border-gray-300 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-50">
|
||||||
|
<Plus className="h-3.5 w-3.5" />
|
||||||
|
{text.editPool}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{publicIPv4s.length === 0 ? (
|
||||||
|
<EmptyState text={text.noPublicIPv4Pool} icon={<Globe2 className="h-7 w-7" />} />
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full min-w-[980px] text-sm">
|
||||||
|
<thead className="border-b border-gray-200 bg-gray-50 text-xs text-gray-500">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 text-left font-medium">IPv4</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium">{text.gateway}</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium">{text.interface}</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium">{text.mask}</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium">{text.assignedTo}</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium">{text.status}</th>
|
||||||
|
<th className="px-4 py-3 text-right font-medium">{text.action}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100">
|
||||||
|
{publicIPv4s.map((ip) => {
|
||||||
|
const assigned = assignedIPv4.get(ip.address)
|
||||||
|
return (
|
||||||
|
<tr key={`${ip.interface}-${ip.address}`} className="hover:bg-gray-50">
|
||||||
|
<td className="px-4 py-3 font-mono text-xs text-gray-700">{ip.address}</td>
|
||||||
|
<td className="px-4 py-3 font-mono text-xs text-gray-600">{ip.gateway || '-'}</td>
|
||||||
|
<td className="px-4 py-3 font-mono text-xs text-gray-600">{ip.interface || '-'}</td>
|
||||||
|
<td className="px-4 py-3 font-mono text-xs text-gray-600">{ip.subnet_mask || (ip.prefix_len ? subnetMaskFromPrefixLen(ip.prefix_len) : '-')}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
{assigned ? (
|
||||||
|
<button onClick={() => navigate(`/container/${assigned.container_id}`)} className="font-medium text-black hover:underline">
|
||||||
|
{assigned.container_name}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-400">{text.available}</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">{assigned ? <StatusBadge status={assigned.status} language={language} /> : <span className="text-xs text-gray-400">{text.free}</span>}</td>
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
<button onClick={() => startEditIPv4Address(ip)} className="inline-flex items-center gap-1.5 rounded-md border border-gray-300 px-2.5 py-1.5 text-xs text-gray-700 hover:bg-gray-50">
|
||||||
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
|
{text.edit}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
{editingIPv4 && (
|
||||||
|
<RouteModal title={ipv4EditMode === 'pool' ? text.editIPv4Pool : text.editIPv4} onClose={closeEditIPv4} wide>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{ipv4EditMode === 'address' && (
|
||||||
|
<div className="rounded-md border border-gray-200 bg-gray-50 px-3 py-2 text-sm">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-medium text-black dark:text-white">NAT4 端口分配</div>
|
<div className="text-xs font-medium uppercase text-gray-400">{text.container}</div>
|
||||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
<div className="mt-1 text-sm text-gray-700">{editingIPv4Assignment?.container_name || text.available}</div>
|
||||||
{nat4Search ? `搜索 "${nat4Search}" 结果 ${filteredNat4.length} 条,` : ''}共 {nat4Mappings.length} 条映射
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{editingIPv4Assignment && (
|
||||||
<div className="relative w-48">
|
<button onClick={() => navigate(`/container/${editingIPv4Assignment.container_id}`)} className="inline-flex items-center gap-1.5 rounded-md border border-gray-300 px-3 py-1.5 text-xs text-gray-700 hover:bg-white">
|
||||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-gray-400" />
|
<Server className="h-3.5 w-3.5" />
|
||||||
<input
|
{text.openContainer}
|
||||||
type="text"
|
|
||||||
value={nat4Search}
|
|
||||||
onChange={e => setNat4Search(e.target.value)}
|
|
||||||
placeholder="搜索端口/容器..."
|
|
||||||
className="w-full pl-8 pr-7 py-1.5 text-xs border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-black dark:text-white focus:outline-none focus:ring-1 focus:ring-black dark:focus:ring-white"
|
|
||||||
/>
|
|
||||||
{nat4Search && (
|
|
||||||
<button onClick={() => setNat4Search('')} className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
|
|
||||||
<X className="w-3 h-3" />
|
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full min-w-[860px] text-sm">
|
||||||
|
<thead className="border-b border-gray-200 bg-gray-50 text-xs text-gray-500">
|
||||||
|
<tr>
|
||||||
|
<th className="px-3 py-2 text-left font-medium">{text.ipv4CIDR}</th>
|
||||||
|
<th className="px-3 py-2 text-left font-medium">{text.gateway}</th>
|
||||||
|
<th className="px-3 py-2 text-left font-medium">{text.interface}</th>
|
||||||
|
<th className="px-3 py-2 text-left font-medium">{text.mask}</th>
|
||||||
|
{ipv4EditMode === 'pool' && <th className="px-3 py-2 text-right font-medium">{text.action}</th>}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100">
|
||||||
|
{ipv4Draft.map((item, index) => (
|
||||||
|
<tr key={`${item.address}-${index}`}>
|
||||||
|
<td className="px-3 py-2"><input value={item.address || ''} onChange={(e) => updateIPv4Draft(index, { address: e.target.value })} placeholder={text.ipv4CIDR} className={smallInputClass} /></td>
|
||||||
|
<td className="px-3 py-2"><input value={item.gateway || ''} onChange={(e) => updateIPv4Draft(index, { gateway: e.target.value })} placeholder={defaultIPv4Gateway || text.gateway} className={smallInputClass} /></td>
|
||||||
|
<td className="px-3 py-2"><input value={item.interface || ''} onChange={(e) => updateIPv4Draft(index, { interface: e.target.value })} placeholder={defaultIPv4Interface} className={smallInputClass} /></td>
|
||||||
|
<td className="px-3 py-2 font-mono text-xs text-gray-500">{item.subnet_mask || (item.prefix_len ? subnetMaskFromPrefixLen(item.prefix_len) : text.auto)}</td>
|
||||||
|
{ipv4EditMode === 'pool' && (
|
||||||
|
<td className="px-3 py-2 text-right">
|
||||||
|
<button onClick={() => setIPv4Draft((items) => items.filter((_, i) => i !== index))} className="inline-flex items-center justify-center rounded p-1.5 text-gray-400 hover:bg-red-50 hover:text-red-600">
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{ipv4Draft.length === 0 && <EmptyRow colSpan={ipv4EditMode === 'pool' ? 5 : 4} text={text.noIPv4InPool} />}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
{ipv4EditMode === 'pool' ? (
|
||||||
|
<button onClick={addIPv4Row} className="inline-flex items-center gap-1.5 rounded-md border border-gray-300 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-50">
|
||||||
|
<Plus className="h-3.5 w-3.5" />
|
||||||
|
{text.addIPv4}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span />
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button onClick={closeEditIPv4} disabled={savingIPv4} className="rounded-md border border-gray-300 px-3 py-1.5 text-xs text-gray-600 hover:bg-gray-50 disabled:opacity-50">
|
||||||
|
{text.cancel}
|
||||||
|
</button>
|
||||||
|
<button onClick={saveIPv4Pool} disabled={savingIPv4} className="inline-flex items-center gap-1.5 rounded-md bg-black px-3 py-1.5 text-xs text-white hover:bg-gray-800 disabled:opacity-50">
|
||||||
|
<Save className="h-3.5 w-3.5" />
|
||||||
|
{savingIPv4 ? text.saving : text.save}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</RouteModal>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{ipv6Prefixes.length > 0 && (
|
||||||
|
<Panel title={text.detectedIPv6Prefixes} subtitle={formatPrefixCount(ipv6Prefixes.length, language)}>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full min-w-[760px] text-sm">
|
||||||
|
<thead className="border-b border-gray-200 bg-gray-50 text-xs text-gray-500">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 text-left font-medium">{text.prefix}</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium">{text.hostAddress}</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium">{text.interface}</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium">{text.gateway}</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium">{text.source}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100">
|
||||||
|
{ipv6Prefixes.map((prefix) => (
|
||||||
|
<tr key={`${prefix.interface}-${prefix.prefix}`} className="hover:bg-gray-50">
|
||||||
|
<td className="px-4 py-3 font-mono text-xs text-gray-700">{prefix.prefix || `${prefix.address}/${prefix.prefix_len}`}</td>
|
||||||
|
<td className="px-4 py-3 font-mono text-xs text-gray-600">{prefix.address || '-'}</td>
|
||||||
|
<td className="px-4 py-3 font-mono text-xs text-gray-600">{prefix.interface || '-'}</td>
|
||||||
|
<td className="px-4 py-3 font-mono text-xs text-gray-600">{prefix.gateway || '-'}</td>
|
||||||
|
<td className="px-4 py-3 text-xs text-gray-500">{formatSource(prefix.source, language)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Panel title={text.ipv4NAT} subtitle={formatMappingSubtitle(filteredNat4.length, nat4Mappings.length, language)} action={<SearchBox value={nat4Search} onChange={setNat4Search} placeholder={text.searchNAT} />}>
|
||||||
{nat4Mappings.length === 0 ? (
|
{nat4Mappings.length === 0 ? (
|
||||||
<EmptyState icon={<Nat4Icon className="h-7 w-7" />} text="暂无 NAT4 端口映射" />
|
<EmptyState text={text.noIPv4NATMappings} icon={<Network className="h-7 w-7" />} />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full min-w-[900px] text-sm">
|
<table className="w-full min-w-[940px] text-sm">
|
||||||
<thead className="border-b border-gray-200 bg-gray-50 text-xs text-gray-500">
|
<thead className="border-b border-gray-200 bg-gray-50 text-xs text-gray-500">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-4 py-3 text-left font-medium">容器</th>
|
<th className="px-4 py-3 text-left font-medium">{text.container}</th>
|
||||||
<th className="px-4 py-3 text-left font-medium">LXC 名称</th>
|
<th className="px-4 py-3 text-left font-medium">{text.runtimeName}</th>
|
||||||
<th className="px-4 py-3 text-left font-medium">容器 IPv4</th>
|
<th className="px-4 py-3 text-left font-medium">{text.guestIPv4}</th>
|
||||||
<th className="px-4 py-3 text-left font-medium">宿主机端口</th>
|
<th className="px-4 py-3 text-left font-medium">{text.hostIPv4}</th>
|
||||||
<th className="px-4 py-3 text-left font-medium">容器端口</th>
|
<th className="px-4 py-3 text-left font-medium">{text.hostPort}</th>
|
||||||
<th className="px-4 py-3 text-left font-medium">协议</th>
|
<th className="px-4 py-3 text-left font-medium">{text.guestPort}</th>
|
||||||
<th className="px-4 py-3 text-left font-medium">说明</th>
|
<th className="px-4 py-3 text-left font-medium">{text.protocol}</th>
|
||||||
<th className="px-4 py-3 text-left font-medium">状态</th>
|
<th className="px-4 py-3 text-left font-medium">{text.status}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-100">
|
<tbody className="divide-y divide-gray-100">
|
||||||
{pagedNat4Mappings.map((mapping, index) => (
|
{pagedNat4Mappings.map((mapping, index) => (
|
||||||
<tr key={`${mapping.container_id}-${mapping.host_port}-${mapping.protocol}-${index}`} className="hover:bg-gray-50">
|
<tr key={`${mapping.container_id}-${mapping.host_ip}-${mapping.host_port}-${mapping.protocol}-${index}`} className="hover:bg-gray-50">
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<button
|
<button onClick={() => navigate(`/container/${mapping.container_id}`)} className="inline-flex items-center gap-2 text-left font-medium text-black hover:underline">
|
||||||
onClick={() => navigate(`/container/${mapping.container_id}`)}
|
|
||||||
className="inline-flex items-center gap-2 text-left font-medium text-black hover:underline"
|
|
||||||
>
|
|
||||||
<Server className="h-4 w-4 text-gray-400" />
|
<Server className="h-4 w-4 text-gray-400" />
|
||||||
{mapping.container_name}
|
{mapping.container_name}
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 font-mono text-xs text-gray-600">{mapping.lxc_name}</td>
|
<td className="px-4 py-3 font-mono text-xs text-gray-600">{mapping.lxc_name}</td>
|
||||||
<td className="px-4 py-3 font-mono text-xs text-gray-600">{mapping.ip || '-'}</td>
|
<td className="px-4 py-3 font-mono text-xs text-gray-600">{mapping.ip || '-'}</td>
|
||||||
|
<td className="px-4 py-3 font-mono text-xs text-gray-700">{mapping.host_ip || text.allIPv4}</td>
|
||||||
<td className="px-4 py-3 font-mono text-xs text-gray-700">{mapping.host_port}</td>
|
<td className="px-4 py-3 font-mono text-xs text-gray-700">{mapping.host_port}</td>
|
||||||
<td className="px-4 py-3 font-mono text-xs text-gray-700">{mapping.container_port}</td>
|
<td className="px-4 py-3 font-mono text-xs text-gray-700">{mapping.container_port}</td>
|
||||||
<td className="px-4 py-3 uppercase text-gray-600">{mapping.protocol || '-'}</td>
|
<td className="px-4 py-3 uppercase text-gray-600">{mapping.protocol || '-'}</td>
|
||||||
<td className="px-4 py-3 text-gray-600">{mapping.description || '-'}</td>
|
<td className="px-4 py-3"><StatusBadge status={mapping.status} language={language} /></td>
|
||||||
<td className="px-4 py-3"><StatusBadge status={mapping.status} /></td>
|
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<Pagination
|
<Pagination page={currentNat4Page} totalPages={nat4TotalPages} totalItems={filteredNat4.length} pageSize={pageSize} onPageChange={setNat4Page} language={language} />
|
||||||
page={currentNat4Page}
|
|
||||||
totalPages={nat4TotalPages}
|
|
||||||
totalItems={filteredNat4.length}
|
|
||||||
pageSize={pageSize}
|
|
||||||
onPageChange={setNat4Page}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Panel>
|
||||||
|
|
||||||
<div className="overflow-hidden rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900">
|
<Panel title={text.ipv6Assignments} subtitle={formatAddressSubtitle(filteredIPv6.length, ipv6Assignments.length, language)} action={<SearchBox value={ipv6Search} onChange={setIPv6Search} placeholder={text.searchIPv6} />}>
|
||||||
<div className="border-b border-gray-200 dark:border-gray-700 px-4 py-3 flex items-center justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<div className="text-sm font-medium text-black dark:text-white">IPv6 地址分配</div>
|
|
||||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
{ipv6Search ? `搜索 "${ipv6Search}" 结果 ${filteredIPv6.length} 条,` : ''}共 {ipv6Assignments.length} 个地址
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="relative w-48">
|
|
||||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-gray-400" />
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={ipv6Search}
|
|
||||||
onChange={e => setIPv6Search(e.target.value)}
|
|
||||||
placeholder="搜索地址/容器..."
|
|
||||||
className="w-full pl-8 pr-7 py-1.5 text-xs border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-black dark:text-white focus:outline-none focus:ring-1 focus:ring-black dark:focus:ring-white"
|
|
||||||
/>
|
|
||||||
{ipv6Search && (
|
|
||||||
<button onClick={() => setIPv6Search('')} className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
|
|
||||||
<X className="w-3 h-3" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{ipv6Assignments.length === 0 ? (
|
{ipv6Assignments.length === 0 ? (
|
||||||
<EmptyState icon={<IPv6Icon className="h-7 w-7" />} text="暂无 IPv6 地址分配" />
|
<EmptyState text={text.noIPv6Assignments} icon={<Router className="h-7 w-7" />} />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full min-w-[820px] text-sm">
|
<table className="w-full min-w-[820px] text-sm">
|
||||||
<thead className="border-b border-gray-200 bg-gray-50 text-xs text-gray-500">
|
<thead className="border-b border-gray-200 bg-gray-50 text-xs text-gray-500">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-4 py-3 text-left font-medium">容器</th>
|
<th className="px-4 py-3 text-left font-medium">{text.container}</th>
|
||||||
<th className="px-4 py-3 text-left font-medium">LXC 名称</th>
|
<th className="px-4 py-3 text-left font-medium">{text.runtimeName}</th>
|
||||||
<th className="px-4 py-3 text-left font-medium">IPv6 地址</th>
|
<th className="px-4 py-3 text-left font-medium">IPv6</th>
|
||||||
<th className="px-4 py-3 text-left font-medium">前缀</th>
|
<th className="px-4 py-3 text-left font-medium">{text.prefix}</th>
|
||||||
<th className="px-4 py-3 text-left font-medium">出口网卡</th>
|
<th className="px-4 py-3 text-left font-medium">{text.interface}</th>
|
||||||
<th className="px-4 py-3 text-left font-medium">状态</th>
|
<th className="px-4 py-3 text-left font-medium">{text.status}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-100">
|
<tbody className="divide-y divide-gray-100">
|
||||||
{pagedIPv6Assignments.map((item) => (
|
{pagedIPv6Assignments.map((item) => (
|
||||||
<tr key={`${item.container_id}-${item.address}`} className="hover:bg-gray-50">
|
<tr key={`${item.container_id}-${item.address}`} className="hover:bg-gray-50">
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<button
|
<button onClick={() => navigate(`/container/${item.container_id}`)} className="inline-flex items-center gap-2 text-left font-medium text-black hover:underline">
|
||||||
onClick={() => navigate(`/container/${item.container_id}`)}
|
|
||||||
className="inline-flex items-center gap-2 text-left font-medium text-black hover:underline"
|
|
||||||
>
|
|
||||||
<Server className="h-4 w-4 text-gray-400" />
|
<Server className="h-4 w-4 text-gray-400" />
|
||||||
{item.container_name}
|
{item.container_name}
|
||||||
</button>
|
</button>
|
||||||
@@ -245,73 +430,109 @@ export default function Routing() {
|
|||||||
<td className="px-4 py-3 font-mono text-xs text-gray-700">{item.address}</td>
|
<td className="px-4 py-3 font-mono text-xs text-gray-700">{item.address}</td>
|
||||||
<td className="px-4 py-3 font-mono text-xs text-gray-600">/{item.prefix_len || '-'}</td>
|
<td className="px-4 py-3 font-mono text-xs text-gray-600">/{item.prefix_len || '-'}</td>
|
||||||
<td className="px-4 py-3 font-mono text-xs text-gray-600">{item.interface || '-'}</td>
|
<td className="px-4 py-3 font-mono text-xs text-gray-600">{item.interface || '-'}</td>
|
||||||
<td className="px-4 py-3"><StatusBadge status={item.status} /></td>
|
<td className="px-4 py-3"><StatusBadge status={item.status} language={language} /></td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<Pagination
|
<Pagination page={currentIPv6Page} totalPages={ipv6TotalPages} totalItems={filteredIPv6.length} pageSize={pageSize} onPageChange={setIPv6Page} language={language} />
|
||||||
page={currentIPv6Page}
|
|
||||||
totalPages={ipv6TotalPages}
|
|
||||||
totalItems={filteredIPv6.length}
|
|
||||||
pageSize={pageSize}
|
|
||||||
onPageChange={setIPv6Page}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Panel({ title, subtitle, action, children }: { title: string; subtitle?: string; action?: ReactNode; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="overflow-hidden rounded-lg border border-gray-200 bg-white">
|
||||||
|
<div className="flex items-center justify-between gap-3 border-b border-gray-200 px-4 py-3">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-black">{title}</div>
|
||||||
|
{subtitle && <div className="mt-1 text-xs text-gray-500">{subtitle}</div>}
|
||||||
|
</div>
|
||||||
|
{action}
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RouteModal({ title, onClose, wide, children }: { title: string; onClose: () => void; wide?: boolean; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||||
|
<div className={`flex max-h-[88vh] w-full flex-col overflow-hidden rounded-lg border border-gray-200 bg-white shadow-xl ${wide ? 'max-w-5xl' : 'max-w-xl'}`}>
|
||||||
|
<div className="flex items-center justify-between gap-3 border-b border-gray-200 px-5 py-4">
|
||||||
|
<div className="text-base font-semibold text-black">{title}</div>
|
||||||
|
<button onClick={onClose} className="rounded p-1 text-gray-500 hover:bg-gray-100">
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-y-auto p-4">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function Pagination({ page, totalPages, totalItems, pageSize, onPageChange }: {
|
function SearchBox({ value, onChange, placeholder }: { value: string; onChange: (value: string) => void; placeholder: string }) {
|
||||||
|
return (
|
||||||
|
<div className="relative w-48">
|
||||||
|
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={value}
|
||||||
|
onChange={(event) => onChange(event.target.value)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className="w-full rounded-md border border-gray-300 bg-white py-1.5 pl-8 pr-7 text-xs text-black focus:outline-none focus:ring-1 focus:ring-black"
|
||||||
|
/>
|
||||||
|
{value && (
|
||||||
|
<button onClick={() => onChange('')} className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600">
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Pagination({ page, totalPages, totalItems, pageSize, onPageChange, language }: {
|
||||||
page: number
|
page: number
|
||||||
totalPages: number
|
totalPages: number
|
||||||
totalItems: number
|
totalItems: number
|
||||||
pageSize: number
|
pageSize: number
|
||||||
onPageChange: (page: number) => void
|
onPageChange: (page: number) => void
|
||||||
|
language: Language
|
||||||
}) {
|
}) {
|
||||||
if (totalPages <= 1) return null
|
if (totalPages <= 1) return null
|
||||||
|
const text = routingText[language]
|
||||||
const start = (page - 1) * pageSize + 1
|
const start = (page - 1) * pageSize + 1
|
||||||
const end = Math.min(page * pageSize, totalItems)
|
const end = Math.min(page * pageSize, totalItems)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between gap-3 border-t border-gray-200 px-4 py-3 text-sm">
|
<div className="flex items-center justify-between gap-3 border-t border-gray-200 px-4 py-3 text-sm">
|
||||||
<div className="text-xs text-gray-500">
|
<div className="text-xs text-gray-500">{formatShowingRange(start, end, totalItems, language)}</div>
|
||||||
显示 {start}-{end},共 {totalItems} 条
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button onClick={() => onPageChange(Math.max(1, page - 1))} disabled={page <= 1} className="rounded-md border border-gray-300 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-50 disabled:opacity-50">
|
||||||
onClick={() => onPageChange(Math.max(1, page - 1))}
|
{text.previous}
|
||||||
disabled={page <= 1}
|
|
||||||
className="rounded-md border border-gray-300 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50"
|
|
||||||
>
|
|
||||||
上一页
|
|
||||||
</button>
|
</button>
|
||||||
<span className="min-w-16 text-center text-xs text-gray-500">
|
<span className="min-w-16 text-center text-xs text-gray-500">{page} / {totalPages}</span>
|
||||||
{page} / {totalPages}
|
<button onClick={() => onPageChange(Math.min(totalPages, page + 1))} disabled={page >= totalPages} className="rounded-md border border-gray-300 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-50 disabled:opacity-50">
|
||||||
</span>
|
{text.next}
|
||||||
<button
|
|
||||||
onClick={() => onPageChange(Math.min(totalPages, page + 1))}
|
|
||||||
disabled={page >= totalPages}
|
|
||||||
className="rounded-md border border-gray-300 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50"
|
|
||||||
>
|
|
||||||
下一页
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function CapacityCard({ title, icon, remaining, total, used, label }: {
|
function CapacityCard({ title, icon, remaining, total, used, label, usedLabel }: {
|
||||||
title: string
|
title: string
|
||||||
icon: React.ReactNode
|
icon: ReactNode
|
||||||
remaining: string
|
remaining: string
|
||||||
total: string
|
total: string
|
||||||
used: number
|
used: number
|
||||||
label: string
|
label: string
|
||||||
|
usedLabel: string
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-gray-200 bg-white p-4">
|
<div className="rounded-lg border border-gray-200 bg-white p-4">
|
||||||
@@ -323,55 +544,260 @@ function CapacityCard({ title, icon, remaining, total, used, label }: {
|
|||||||
<span className="pb-1 text-sm text-gray-400">/ {total}</span>
|
<span className="pb-1 text-sm text-gray-400">/ {total}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-md bg-gray-100">
|
<div className="flex h-10 w-10 items-center justify-center rounded-md bg-gray-100 text-gray-700">{icon}</div>
|
||||||
{icon}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 text-xs text-gray-500">{label}</div>
|
<div className="mt-3 text-xs text-gray-500">{label}</div>
|
||||||
<div className="mt-1 text-xs text-gray-400">已分配 {used}</div>
|
<div className="mt-1 text-xs text-gray-400">{usedLabel} {used}</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function EmptyState({ icon, text }: { icon: React.ReactNode; text: string }) {
|
function EmptyState({ icon, text }: { icon: ReactNode; text: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center px-6 py-16 text-center">
|
<div className="flex flex-col items-center justify-center px-6 py-16 text-center">
|
||||||
<div className="mb-4 flex h-14 w-14 items-center justify-center rounded-lg bg-gray-100">
|
<div className="mb-4 flex h-14 w-14 items-center justify-center rounded-lg bg-gray-100 text-gray-500">{icon}</div>
|
||||||
{icon}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm font-medium text-gray-700">{text}</div>
|
<div className="text-sm font-medium text-gray-700">{text}</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatusBadge({ status }: { status: string }) {
|
function EmptyRow({ colSpan, text }: { colSpan: number; text: string }) {
|
||||||
|
return (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={colSpan} className="px-3 py-8 text-center text-sm text-gray-400">{text}</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusBadge({ status, language }: { status: string; language: Language }) {
|
||||||
const running = status === 'running'
|
const running = status === 'running'
|
||||||
return (
|
return (
|
||||||
<span className={`rounded px-2 py-1 text-xs ${running ? 'bg-green-50 text-green-700' : 'bg-gray-100 text-gray-700'}`}>
|
<span className={`rounded px-2 py-1 text-xs ${running ? 'bg-green-50 text-green-700' : 'bg-gray-100 text-gray-700'}`}>
|
||||||
{running ? '运行中' : (status || '未知')}
|
{formatContainerStatus(status, language)}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatCapacity(value: string): string {
|
function matchesNat4(item: NAT4Route, query: string) {
|
||||||
if (value === 'large') return '充足'
|
return (
|
||||||
|
String(item.host_port).includes(query) ||
|
||||||
|
String(item.container_port).includes(query) ||
|
||||||
|
item.container_name.toLowerCase().includes(query) ||
|
||||||
|
item.lxc_name.toLowerCase().includes(query) ||
|
||||||
|
(item.ip || '').toLowerCase().includes(query) ||
|
||||||
|
(item.host_ip || '').toLowerCase().includes(query)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesIPv6(item: IPv6Route, query: string) {
|
||||||
|
return (
|
||||||
|
(item.address || '').toLowerCase().includes(query) ||
|
||||||
|
item.container_name.toLowerCase().includes(query) ||
|
||||||
|
item.lxc_name.toLowerCase().includes(query) ||
|
||||||
|
(item.interface || '').toLowerCase().includes(query)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCapacity(value: string, language: Language): string {
|
||||||
|
if (value === 'large') return routingText[language].large
|
||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
function Nat4Icon({ className }: { className?: string }) {
|
function subnetMaskFromPrefixLen(prefixLen: number): string {
|
||||||
return (
|
if (!Number.isFinite(prefixLen) || prefixLen < 0 || prefixLen > 32) return '-'
|
||||||
<svg className={className} viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg" fill="currentColor">
|
const mask = prefixLen === 0 ? 0 : (0xffffffff << (32 - prefixLen)) >>> 0
|
||||||
<path d="M797.866667 128c64 0 115.2 51.2 119.466666 110.933333v558.933334c0 64-51.2 115.2-110.933333 119.466666H243.2c-59.733333 0-110.933333-51.2-115.2-110.933333V247.466667C128 187.733333 174.933333 136.533333 234.666667 128h563.2z m38.4 473.6H204.8v196.266667c0 21.333333 17.066667 38.4 38.4 38.4h554.666667c21.333333 0 38.4-17.066667 38.4-38.4v-196.266667z m-315.733334 76.8c21.333333 0 38.4 17.066667 38.4 42.666667 0 17.066667-12.8 34.133333-34.133333 38.4H320c-21.333333 0-38.4-17.066667-38.4-42.666667 0-17.066667 12.8-34.133333 34.133333-38.4h204.8z m157.866667 0c21.333333 0 38.4 17.066667 38.4 42.666667 0 17.066667-12.8 34.133333-34.133333 38.4h-46.933334c-21.333333 0-38.4-17.066667-38.4-42.666667 0-17.066667 12.8-34.133333 34.133334-38.4h46.933333z m119.466667-473.6h-554.666667c-21.333333 0-38.4 17.066667-38.4 38.4v277.333333h631.466667V243.2c0-17.066667-17.066667-34.133333-38.4-38.4z" />
|
return [24, 16, 8, 0].map((shift) => (mask >>> shift) & 255).join('.')
|
||||||
<path d="M277.333333 426.666667V243.2h34.133334V426.666667h-34.133334zM426.666667 358.4h-34.133334V426.666667h-34.133333V243.2h72.533333c38.4 0 59.733333 25.6 59.733334 55.466667s-25.6 59.733333-64 59.733333z m-4.266667-81.066667h-34.133333v51.2h34.133333c17.066667 0 25.6-8.533333 25.6-25.6s-8.533333-25.6-25.6-25.6zM571.733333 426.666667h-25.6l-51.2-132.266667h34.133334l25.6 81.066667 25.6-81.066667h34.133333l-42.666667 132.266667zM733.866667 401.066667v25.6h-34.133334v-25.6h-72.533333v-29.866667l64-123.733333h38.4l-64 123.733333h38.4v-34.133333h34.133333v34.133333h17.066667v29.866667h-21.333333z" />
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function IPv6Icon({ className }: { className?: string }) {
|
function mergeIPv4PoolItem(pool: PublicIPv4Info[], originalAddress: string, replacement: PublicIPv4Info): PublicIPv4Info[] {
|
||||||
return (
|
let replaced = false
|
||||||
<svg className={className} viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg" fill="currentColor">
|
const next = pool.map((item) => {
|
||||||
<path d="M797.866667 128c64 0 115.2 51.2 119.466666 110.933333v558.933334c0 64-51.2 115.2-110.933333 119.466666H243.2c-59.733333 0-110.933333-51.2-115.2-110.933333V247.466667C128 187.733333 174.933333 136.533333 234.666667 128h563.2z m38.4 473.6H204.8v196.266667c0 21.333333 17.066667 38.4 38.4 38.4h554.666667c21.333333 0 38.4-17.066667 38.4-38.4v-196.266667z m-315.733334 76.8c21.333333 0 38.4 17.066667 38.4 42.666667 0 17.066667-12.8 34.133333-34.133333 38.4H320c-21.333333 0-38.4-17.066667-38.4-42.666667 0-17.066667 12.8-34.133333 34.133333-38.4h204.8z m157.866667 0c21.333333 0 38.4 17.066667 38.4 42.666667 0 17.066667-12.8 34.133333-34.133333 38.4h-46.933334c-21.333333 0-38.4-17.066667-38.4-42.666667 0-17.066667 12.8-34.133333 34.133334-38.4h46.933333z m119.466667-473.6h-554.666667c-21.333333 0-38.4 17.066667-38.4 38.4v277.333333h631.466667V243.2c0-17.066667-17.066667-34.133333-38.4-38.4z" />
|
if (item.address !== originalAddress) return item
|
||||||
<path d="M277.333333 426.666667V243.2h34.133334V426.666667h-34.133334zM426.666667 358.4h-34.133334V426.666667h-34.133333V243.2h72.533333c38.4 0 59.733333 25.6 59.733334 55.466667s-25.6 59.733333-64 59.733333z m-4.266667-81.066667h-34.133333v51.2h34.133333c17.066667 0 25.6-8.533333 25.6-25.6s-8.533333-25.6-25.6-25.6zM571.733333 426.666667h-25.6l-51.2-132.266667h34.133334l25.6 81.066667 25.6-81.066667h34.133333l-42.666667 132.266667zM691.2 426.666667c-34.133333 0-55.466667-21.333333-55.466667-55.466667 0-17.066667 8.533333-34.133333 17.066667-46.933333l38.4-76.8h38.4l-38.4 76.8c4.266667 0 8.533333-4.266667 12.8-4.266667 25.6 0 46.933333 21.333333 46.933333 55.466667-4.266667 29.866667-29.866667 51.2-59.733333 51.2z m0-81.066667c-12.8 0-25.6 8.533333-25.6 25.6 0 17.066667 8.533333 25.6 25.6 25.6s25.6-8.533333 25.6-25.6c-4.266667-17.066667-12.8-25.6-25.6-25.6z" />
|
replaced = true
|
||||||
</svg>
|
return replacement
|
||||||
)
|
})
|
||||||
|
if (!replaced) {
|
||||||
|
next.push(replacement)
|
||||||
}
|
}
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
const routingText = {
|
||||||
|
zh: {
|
||||||
|
pageTitle: '路由管理',
|
||||||
|
pageSubtitle: 'NAT4、公网 IPv4 池和 IPv6 地址分配',
|
||||||
|
refresh: '刷新',
|
||||||
|
nat4Ports: 'NAT4 端口',
|
||||||
|
remainingTotal: '剩余 / 总数',
|
||||||
|
publicIPv4: '公网 IPv4',
|
||||||
|
publicIPv4Pool: '公网 IPv4 池',
|
||||||
|
editPool: '编辑 IP 池',
|
||||||
|
noPublicIPv4Pool: '暂未配置公网 IPv4 池',
|
||||||
|
gateway: '网关',
|
||||||
|
interface: '网卡',
|
||||||
|
mask: '掩码',
|
||||||
|
assignedTo: '分配给',
|
||||||
|
status: '状态',
|
||||||
|
action: '操作',
|
||||||
|
available: '可用',
|
||||||
|
free: '空闲',
|
||||||
|
edit: '修改',
|
||||||
|
editIPv4Pool: '编辑 IPv4 池',
|
||||||
|
editIPv4: '修改 IPv4',
|
||||||
|
ipv4GatewayRequired: 'IPv4 网关不能为空',
|
||||||
|
ipv4AddressRequired: 'IPv4 地址不能为空',
|
||||||
|
saveIPv4PoolFailed: '保存 IPv4 池失败',
|
||||||
|
ipv4CIDR: 'IPv4 / CIDR',
|
||||||
|
container: '容器',
|
||||||
|
openContainer: '打开容器',
|
||||||
|
auto: '自动',
|
||||||
|
noIPv4InPool: 'IPv4 池内暂无地址',
|
||||||
|
addIPv4: '添加 IPv4',
|
||||||
|
cancel: '取消',
|
||||||
|
save: '保存',
|
||||||
|
saving: '保存中...',
|
||||||
|
detectedIPv6Prefixes: '检测到的 IPv6 前缀',
|
||||||
|
prefix: '前缀',
|
||||||
|
hostAddress: '宿主地址',
|
||||||
|
source: '来源',
|
||||||
|
local: '本机',
|
||||||
|
ipv4NAT: 'IPv4 NAT',
|
||||||
|
searchNAT: '搜索 NAT...',
|
||||||
|
noIPv4NATMappings: '暂无 IPv4 NAT 映射',
|
||||||
|
runtimeName: '运行时名称',
|
||||||
|
guestIPv4: '客户机 IPv4',
|
||||||
|
hostIPv4: '宿主 IPv4',
|
||||||
|
hostPort: '宿主端口',
|
||||||
|
guestPort: '客户机端口',
|
||||||
|
protocol: '协议',
|
||||||
|
allIPv4: '全部 IPv4',
|
||||||
|
ipv6Assignments: 'IPv6 地址分配',
|
||||||
|
searchIPv6: '搜索 IPv6...',
|
||||||
|
noIPv6Assignments: '暂无 IPv6 地址分配',
|
||||||
|
previous: '上一页',
|
||||||
|
next: '下一页',
|
||||||
|
used: '已用',
|
||||||
|
running: '运行中',
|
||||||
|
stopped: '已停止',
|
||||||
|
unknown: '未知',
|
||||||
|
large: '大量',
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
pageTitle: 'Routing',
|
||||||
|
pageSubtitle: 'NAT4, public IPv4 pool, and IPv6 assignments',
|
||||||
|
refresh: 'Refresh',
|
||||||
|
nat4Ports: 'NAT4 ports',
|
||||||
|
remainingTotal: 'remaining / total',
|
||||||
|
publicIPv4: 'Public IPv4',
|
||||||
|
publicIPv4Pool: 'Public IPv4 pool',
|
||||||
|
editPool: 'Edit pool',
|
||||||
|
noPublicIPv4Pool: 'No public IPv4 pool configured',
|
||||||
|
gateway: 'Gateway',
|
||||||
|
interface: 'Interface',
|
||||||
|
mask: 'Mask',
|
||||||
|
assignedTo: 'Assigned to',
|
||||||
|
status: 'Status',
|
||||||
|
action: 'Action',
|
||||||
|
available: 'Available',
|
||||||
|
free: 'Free',
|
||||||
|
edit: 'Edit',
|
||||||
|
editIPv4Pool: 'Edit IPv4 pool',
|
||||||
|
editIPv4: 'Edit IPv4',
|
||||||
|
ipv4GatewayRequired: 'IPv4 gateway is required',
|
||||||
|
ipv4AddressRequired: 'IPv4 address is required',
|
||||||
|
saveIPv4PoolFailed: 'Save IPv4 pool failed',
|
||||||
|
ipv4CIDR: 'IPv4 / CIDR',
|
||||||
|
container: 'Container',
|
||||||
|
openContainer: 'Open container',
|
||||||
|
auto: 'Auto',
|
||||||
|
noIPv4InPool: 'No IPv4 addresses in the pool',
|
||||||
|
addIPv4: 'Add IPv4',
|
||||||
|
cancel: 'Cancel',
|
||||||
|
save: 'Save',
|
||||||
|
saving: 'Saving...',
|
||||||
|
detectedIPv6Prefixes: 'Detected IPv6 prefixes',
|
||||||
|
prefix: 'Prefix',
|
||||||
|
hostAddress: 'Host address',
|
||||||
|
source: 'Source',
|
||||||
|
local: 'local',
|
||||||
|
ipv4NAT: 'IPv4 NAT',
|
||||||
|
searchNAT: 'Search NAT...',
|
||||||
|
noIPv4NATMappings: 'No IPv4 NAT mappings',
|
||||||
|
runtimeName: 'Runtime name',
|
||||||
|
guestIPv4: 'Guest IPv4',
|
||||||
|
hostIPv4: 'Host IPv4',
|
||||||
|
hostPort: 'Host port',
|
||||||
|
guestPort: 'Guest port',
|
||||||
|
protocol: 'Protocol',
|
||||||
|
allIPv4: 'All IPv4',
|
||||||
|
ipv6Assignments: 'IPv6 assignments',
|
||||||
|
searchIPv6: 'Search IPv6...',
|
||||||
|
noIPv6Assignments: 'No IPv6 assignments',
|
||||||
|
previous: 'Previous',
|
||||||
|
next: 'Next',
|
||||||
|
used: 'Used',
|
||||||
|
running: 'Running',
|
||||||
|
stopped: 'Stopped',
|
||||||
|
unknown: 'Unknown',
|
||||||
|
large: 'large',
|
||||||
|
},
|
||||||
|
} as const
|
||||||
|
|
||||||
|
function formatPoolCount(count: number, language: Language) {
|
||||||
|
return language === 'en' ? `${count} in pool` : `池内 ${count} 个`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatIPv4PoolSubtitle(total: number, assigned: number, language: Language) {
|
||||||
|
return language === 'en'
|
||||||
|
? `${formatAddressCount(total, language)} in pool, ${assigned} assigned`
|
||||||
|
: `池内 ${total} 个地址,已分配 ${assigned} 个`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDetectedPrefixCount(count: number, language: Language) {
|
||||||
|
return language === 'en'
|
||||||
|
? `${count} detected ${count === 1 ? 'prefix' : 'prefixes'}`
|
||||||
|
: `检测到 ${count} 个前缀`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPrefixCount(count: number, language: Language) {
|
||||||
|
return language === 'en' ? `${count} ${count === 1 ? 'prefix' : 'prefixes'}` : `${count} 个前缀`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMappingSubtitle(filtered: number, total: number, language: Language) {
|
||||||
|
return language === 'en'
|
||||||
|
? `${filtered} of ${total} ${total === 1 ? 'mapping' : 'mappings'}`
|
||||||
|
: `显示 ${filtered} 条,共 ${total} 条映射`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAddressSubtitle(filtered: number, total: number, language: Language) {
|
||||||
|
return language === 'en'
|
||||||
|
? `${filtered} of ${total} ${total === 1 ? 'address' : 'addresses'}`
|
||||||
|
: `显示 ${filtered} 个,共 ${total} 个地址`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAddressCount(count: number, language: Language) {
|
||||||
|
return language === 'en' ? `${count} ${count === 1 ? 'address' : 'addresses'}` : `${count} 个地址`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatShowingRange(start: number, end: number, total: number, language: Language) {
|
||||||
|
return language === 'en' ? `Showing ${start}-${end} of ${total}` : `显示 ${start}-${end},共 ${total} 条`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatContainerStatus(status: string, language: Language) {
|
||||||
|
const text = routingText[language]
|
||||||
|
switch ((status || '').toLowerCase()) {
|
||||||
|
case 'running':
|
||||||
|
return text.running
|
||||||
|
case 'stopped':
|
||||||
|
return text.stopped
|
||||||
|
default:
|
||||||
|
return status || text.unknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSource(source: string | undefined, language: Language) {
|
||||||
|
if (!source || source === 'local') return routingText[language].local
|
||||||
|
return source
|
||||||
|
}
|
||||||
|
|
||||||
|
const smallInputClass = 'w-full rounded border border-gray-300 px-2 py-1.5 font-mono text-xs text-gray-800 focus:outline-none focus:ring-1 focus:ring-black'
|
||||||
|
|||||||
@@ -40,10 +40,24 @@ export type ContainerIdentifier = number | string
|
|||||||
export interface PortMapping {
|
export interface PortMapping {
|
||||||
container_port: number
|
container_port: number
|
||||||
host_port: number
|
host_port: number
|
||||||
|
host_ip?: string
|
||||||
protocol: string
|
protocol: string
|
||||||
description: string
|
description: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PublicIPv4Assignment {
|
||||||
|
address: string
|
||||||
|
interface?: string
|
||||||
|
prefix_len?: number
|
||||||
|
gateway?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IPv6Assignment {
|
||||||
|
address: string
|
||||||
|
prefix_len: number
|
||||||
|
interface?: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface Container {
|
export interface Container {
|
||||||
id: number
|
id: number
|
||||||
uuid: string
|
uuid: string
|
||||||
@@ -64,9 +78,11 @@ export interface Container {
|
|||||||
io_speed_mbps: number
|
io_speed_mbps: number
|
||||||
status: string
|
status: string
|
||||||
ip: string
|
ip: string
|
||||||
|
public_ipv4s?: PublicIPv4Assignment[]
|
||||||
ipv6: string
|
ipv6: string
|
||||||
ipv6_prefix_len: number
|
ipv6_prefix_len: number
|
||||||
ipv6_interface: string
|
ipv6_interface: string
|
||||||
|
ipv6_addresses?: IPv6Assignment[]
|
||||||
vnc_port: number
|
vnc_port: number
|
||||||
ssh_port: number
|
ssh_port: number
|
||||||
ssh_password: string
|
ssh_password: string
|
||||||
@@ -114,8 +130,14 @@ export interface CreateContainerRequest {
|
|||||||
io_speed_mbps: number
|
io_speed_mbps: number
|
||||||
extra_ports: number[]
|
extra_ports: number[]
|
||||||
port_mapping_count: number
|
port_mapping_count: number
|
||||||
|
assign_nat?: boolean
|
||||||
snapshot_limit: number
|
snapshot_limit: number
|
||||||
|
assign_ipv4?: boolean
|
||||||
|
ipv4_count?: number
|
||||||
|
public_ipv4s?: string[]
|
||||||
assign_ipv6: boolean
|
assign_ipv6: boolean
|
||||||
|
ipv6_count?: number
|
||||||
|
ipv6_addresses?: string[]
|
||||||
expires_at: string
|
expires_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,6 +158,17 @@ export interface IPv6Status {
|
|||||||
prefixes: IPv6PrefixInfo[]
|
prefixes: IPv6PrefixInfo[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PublicIPv4Info {
|
||||||
|
interface: string
|
||||||
|
address: string
|
||||||
|
prefix: string
|
||||||
|
prefix_len?: number
|
||||||
|
subnet_mask?: string
|
||||||
|
gateway?: string
|
||||||
|
is_tunnel?: boolean
|
||||||
|
source?: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface IPv4PrefixInfo {
|
export interface IPv4PrefixInfo {
|
||||||
interface: string
|
interface: string
|
||||||
address: string
|
address: string
|
||||||
@@ -163,6 +196,7 @@ export interface HostInfo {
|
|||||||
tx_bps: number
|
tx_bps: number
|
||||||
public_ipv4?: string
|
public_ipv4?: string
|
||||||
public_ipv4_interface?: string
|
public_ipv4_interface?: string
|
||||||
|
public_ipv4_addresses?: PublicIPv4Info[]
|
||||||
public_ipv6?: string
|
public_ipv6?: string
|
||||||
public_ipv6_interface?: string
|
public_ipv6_interface?: string
|
||||||
ipv6_prefixes?: IPv6PrefixInfo[]
|
ipv6_prefixes?: IPv6PrefixInfo[]
|
||||||
@@ -207,6 +241,7 @@ export interface HostProbeReport {
|
|||||||
serial: string
|
serial: string
|
||||||
size_bytes: number
|
size_bytes: number
|
||||||
type: string
|
type: string
|
||||||
|
virtual?: boolean
|
||||||
rotational: boolean
|
rotational: boolean
|
||||||
mountpoints: string[]
|
mountpoints: string[]
|
||||||
health: string
|
health: string
|
||||||
@@ -453,12 +488,24 @@ export interface NAT4Route {
|
|||||||
lxc_name: string
|
lxc_name: string
|
||||||
status: string
|
status: string
|
||||||
ip: string
|
ip: string
|
||||||
|
host_ip: string
|
||||||
host_port: number
|
host_port: number
|
||||||
container_port: number
|
container_port: number
|
||||||
protocol: string
|
protocol: string
|
||||||
description: string
|
description: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface IPv4Route {
|
||||||
|
container_id: number
|
||||||
|
container_name: string
|
||||||
|
lxc_name: string
|
||||||
|
status: string
|
||||||
|
address: string
|
||||||
|
interface: string
|
||||||
|
prefix_len?: number
|
||||||
|
gateway?: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface IPv6Route {
|
export interface IPv6Route {
|
||||||
container_id: number
|
container_id: number
|
||||||
container_name: string
|
container_name: string
|
||||||
@@ -471,15 +518,37 @@ export interface IPv6Route {
|
|||||||
|
|
||||||
export interface RoutingInfo {
|
export interface RoutingInfo {
|
||||||
nat4: RouteCapacity
|
nat4: RouteCapacity
|
||||||
|
ipv4: RouteCapacity
|
||||||
ipv6: RouteCapacity
|
ipv6: RouteCapacity
|
||||||
|
host_public_ipv4?: PublicIPv4Info
|
||||||
|
public_ipv4_addresses: PublicIPv4Info[]
|
||||||
|
ipv4_assignments: IPv4Route[]
|
||||||
nat4_mappings: NAT4Route[]
|
nat4_mappings: NAT4Route[]
|
||||||
ipv6_assignments: IPv6Route[]
|
ipv6_assignments: IPv6Route[]
|
||||||
ipv6_prefixes: IPv6PrefixInfo[]
|
ipv6_prefixes: IPv6PrefixInfo[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PublicIPv4ScanResult extends PublicIPv4Info {
|
||||||
|
status: string
|
||||||
|
usable: boolean
|
||||||
|
reason: string
|
||||||
|
}
|
||||||
|
|
||||||
export const getRoutingInfo = () =>
|
export const getRoutingInfo = () =>
|
||||||
api.get<APIResponse<RoutingInfo>>('/routing')
|
api.get<APIResponse<RoutingInfo>>('/routing')
|
||||||
|
|
||||||
|
export const updateRoutingPools = (payload: { items?: PublicIPv4Info[]; ipv6_prefixes?: IPv6PrefixInfo[] }) =>
|
||||||
|
api.put<APIResponse<RoutingInfo>>('/routing', payload)
|
||||||
|
|
||||||
|
export const updateRoutingIPv4Pool = (items: PublicIPv4Info[]) =>
|
||||||
|
updateRoutingPools({ items })
|
||||||
|
|
||||||
|
export const updateRoutingIPv6Prefixes = (ipv6_prefixes: IPv6PrefixInfo[]) =>
|
||||||
|
updateRoutingPools({ ipv6_prefixes })
|
||||||
|
|
||||||
|
export const scanRoutingIPv4Segment = (payload: { cidr: string; interface: string; gateway: string; verify: boolean; limit?: number }) =>
|
||||||
|
api.post<APIResponse<PublicIPv4ScanResult[]>>('/routing/ipv4-scan', payload)
|
||||||
|
|
||||||
// Templates
|
// Templates
|
||||||
export const getTemplates = () =>
|
export const getTemplates = () =>
|
||||||
api.get<APIResponse<Template[]>>('/templates')
|
api.get<APIResponse<Template[]>>('/templates')
|
||||||
|
|||||||
@@ -245,6 +245,7 @@ const exact: Record<string, string> = {
|
|||||||
'暂无登录记录': 'No login records',
|
'暂无登录记录': 'No login records',
|
||||||
'暂无 NAT4 端口映射': 'No NAT4 port mappings',
|
'暂无 NAT4 端口映射': 'No NAT4 port mappings',
|
||||||
'暂无 IPv6 地址分配': 'No IPv6 assignments',
|
'暂无 IPv6 地址分配': 'No IPv6 assignments',
|
||||||
|
'暂无可分配 IPv6 前缀': 'No allocatable IPv6 prefixes',
|
||||||
'暂无镜像': 'No images',
|
'暂无镜像': 'No images',
|
||||||
'暂无数据': 'No data',
|
'暂无数据': 'No data',
|
||||||
'容器': 'Container',
|
'容器': 'Container',
|
||||||
@@ -327,6 +328,7 @@ const exact: Record<string, string> = {
|
|||||||
'地址': 'Address',
|
'地址': 'Address',
|
||||||
'前缀': 'Prefix',
|
'前缀': 'Prefix',
|
||||||
'出口网卡': 'Uplink',
|
'出口网卡': 'Uplink',
|
||||||
|
'宿主地址': 'Host Address',
|
||||||
'协议': 'Protocol',
|
'协议': 'Protocol',
|
||||||
'说明': 'Description',
|
'说明': 'Description',
|
||||||
'端口': 'Port',
|
'端口': 'Port',
|
||||||
@@ -334,6 +336,12 @@ const exact: Record<string, string> = {
|
|||||||
'宿主机端口': 'Host Port',
|
'宿主机端口': 'Host Port',
|
||||||
'容器 IPv4': 'Container IPv4',
|
'容器 IPv4': 'Container IPv4',
|
||||||
'IPv6 地址': 'IPv6 Address',
|
'IPv6 地址': 'IPv6 Address',
|
||||||
|
'IPv6 前缀': 'IPv6 Prefix',
|
||||||
|
'可分配 IPv6 前缀': 'Allocatable IPv6 Prefixes',
|
||||||
|
'编辑前缀': 'Edit Prefixes',
|
||||||
|
'添加 IPv6 前缀': 'Add IPv6 Prefix',
|
||||||
|
'保存前缀': 'Save Prefixes',
|
||||||
|
'服务商面板里的额外 IPv6 段不会自动出现在网卡里,请把可分配的前缀手动填入这里,例如 2401:b60:26:5e::2/64。': 'Extra IPv6 prefixes from the provider panel will not automatically appear on the NIC. Enter allocatable prefixes here manually, for example 2401:b60:26:5e::2/64.',
|
||||||
'LXC 名称': 'LXC Name',
|
'LXC 名称': 'LXC Name',
|
||||||
'快照时间': 'Snapshot Time',
|
'快照时间': 'Snapshot Time',
|
||||||
'删除快照': 'Delete Snapshot',
|
'删除快照': 'Delete Snapshot',
|
||||||
@@ -730,6 +738,10 @@ const exact: Record<string, string> = {
|
|||||||
'厂商': 'Vendor',
|
'厂商': 'Vendor',
|
||||||
'型号/序列号': 'Model / Serial',
|
'型号/序列号': 'Model / Serial',
|
||||||
'未检测到硬盘': 'No disks detected',
|
'未检测到硬盘': 'No disks detected',
|
||||||
|
'虚拟磁盘': 'Virtual Disk',
|
||||||
|
'不支持': 'Unsupported',
|
||||||
|
'虚拟磁盘,真实 SMART/寿命/通电数据需在物理宿主机查看': 'Virtual disk. Real SMART, lifetime, and power-on data must be checked on the physical host.',
|
||||||
|
'虚拟Disk,真实 SMART/Lifetime/Power-on数据需在物理宿主机View': 'Virtual disk. Real SMART, lifetime, and power-on data must be checked on the physical host.',
|
||||||
'型号': 'Model',
|
'型号': 'Model',
|
||||||
'挂载点': 'Mount Point',
|
'挂载点': 'Mount Point',
|
||||||
'寿命': 'Lifetime',
|
'寿命': 'Lifetime',
|
||||||
@@ -782,10 +794,16 @@ const artifactPatterns: RegExp[] = [
|
|||||||
/实时\s*Status/,
|
/实时\s*Status/,
|
||||||
/Create\s*Time/,
|
/Create\s*Time/,
|
||||||
/长期\s*Valid/,
|
/长期\s*Valid/,
|
||||||
|
/虚拟Disk/,
|
||||||
|
/宿主机View/,
|
||||||
|
/SMART\/Lifetime\/Power-on数据/,
|
||||||
]
|
]
|
||||||
|
|
||||||
const replacements: Array<[RegExp, string]> = [
|
const replacements: Array<[RegExp, string]> = [
|
||||||
[/Back\s*列表/g, 'Back to list'],
|
[/Back\s*列表/g, 'Back to list'],
|
||||||
|
[/虚拟Disk,真实 SMART\/Lifetime\/Power-on数据需在物理宿主机View/g, 'Virtual disk. Real SMART, lifetime, and power-on data must be checked on the physical host.'],
|
||||||
|
[/虚拟\s*Disk,真实 SMART\/Lifetime\/Power-on数据需在物理宿主机\s*View/g, 'Virtual disk. Real SMART, lifetime, and power-on data must be checked on the physical host.'],
|
||||||
|
[/真实 SMART\/Lifetime\/Power-on数据需在物理宿主机View/g, 'Real SMART, lifetime, and power-on data must be checked on the physical host'],
|
||||||
[/Search\s*名称、ID、UUID、IP/g, 'Search name, ID, UUID, IP'],
|
[/Search\s*名称、ID、UUID、IP/g, 'Search name, ID, UUID, IP'],
|
||||||
[/All\s*类型/g, 'All types'],
|
[/All\s*类型/g, 'All types'],
|
||||||
[/All\s*系统/g, 'All systems'],
|
[/All\s*系统/g, 'All systems'],
|
||||||
@@ -817,6 +835,7 @@ const replacements: Array<[RegExp, string]> = [
|
|||||||
[/告警列表\s*\((\d+)\)/g, 'Alert List ($1)'],
|
[/告警列表\s*\((\d+)\)/g, 'Alert List ($1)'],
|
||||||
[/共\s*(\d+)\s*个\s*Container/g, 'Total $1 containers'],
|
[/共\s*(\d+)\s*个\s*Container/g, 'Total $1 containers'],
|
||||||
[/共\s*(\d+)\s*个\s*容器/g, 'Total $1 containers'],
|
[/共\s*(\d+)\s*个\s*容器/g, 'Total $1 containers'],
|
||||||
|
[/(\d+)\s*个前缀,(\d+)\s*个地址已分配/g, '$1 prefixes, $2 addresses assigned'],
|
||||||
[/共\s*(\d+)\s*条/g, 'Total $1'],
|
[/共\s*(\d+)\s*条/g, 'Total $1'],
|
||||||
[/共\s*(\d+)\s*个/g, 'Total $1 items'],
|
[/共\s*(\d+)\s*个/g, 'Total $1 items'],
|
||||||
[/,筛选后\s*(\d+)\s*个/g, ', filtered $1 items'],
|
[/,筛选后\s*(\d+)\s*个/g, ', filtered $1 items'],
|
||||||
@@ -898,6 +917,11 @@ export function shouldTranslateText(value: string): boolean {
|
|||||||
|
|
||||||
function cleanupTranslatedText(value: string): string {
|
function cleanupTranslatedText(value: string): string {
|
||||||
return value
|
return value
|
||||||
|
.replace(/虚拟Disk,真实 SMART\/Lifetime\/Power-on数据需在物理宿主机View/g, 'Virtual disk. Real SMART, lifetime, and power-on data must be checked on the physical host.')
|
||||||
|
.replace(/Virtual Disk,真实 SMART\/Lifetime\/Power-on数据需在物理Host View/g, 'Virtual disk. Real SMART, lifetime, and power-on data must be checked on the physical host.')
|
||||||
|
.replace(/Virtual Disk,真实 SMART\/Lifetime\/Power-on数据需在物理宿主机View/g, 'Virtual disk. Real SMART, lifetime, and power-on data must be checked on the physical host.')
|
||||||
|
.replace(/虚拟\s*Disk/g, 'Virtual disk')
|
||||||
|
.replace(/宿主机\s*View/g, 'physical host')
|
||||||
.replace(/Back\s*List/g, 'Back to list')
|
.replace(/Back\s*List/g, 'Back to list')
|
||||||
.replace(/Container\s*List/g, 'Container List')
|
.replace(/Container\s*List/g, 'Container List')
|
||||||
.replace(/Snapshot\s*List/g, 'Snapshot List')
|
.replace(/Snapshot\s*List/g, 'Snapshot List')
|
||||||
|
|||||||
+5
-3
@@ -958,7 +958,7 @@ install_apk() {
|
|||||||
libvirt-client \
|
libvirt-client \
|
||||||
libvirt-qemu
|
libvirt-qemu
|
||||||
|
|
||||||
for pkg in lxcfs shadow conntrack-tools quota-tools e2fsprogs xfsprogs cloud-utils genisoimage xorriso; do
|
for pkg in lxcfs shadow conntrack-tools quota-tools e2fsprogs xfsprogs cloud-utils genisoimage xorriso smartmontools; do
|
||||||
apk add --no-cache "$pkg" >/dev/null 2>&1 || warn "可选依赖未安装:$pkg"
|
apk add --no-cache "$pkg" >/dev/null 2>&1 || warn "可选依赖未安装:$pkg"
|
||||||
done
|
done
|
||||||
}
|
}
|
||||||
@@ -987,12 +987,14 @@ install_apt() {
|
|||||||
xfsprogs \
|
xfsprogs \
|
||||||
dnsmasq-base \
|
dnsmasq-base \
|
||||||
qemu-kvm \
|
qemu-kvm \
|
||||||
|
qemu-system-x86 \
|
||||||
qemu-utils \
|
qemu-utils \
|
||||||
libvirt-daemon-system \
|
libvirt-daemon-system \
|
||||||
libvirt-clients \
|
libvirt-clients \
|
||||||
cloud-image-utils \
|
cloud-image-utils \
|
||||||
genisoimage \
|
genisoimage \
|
||||||
xorriso \
|
xorriso \
|
||||||
|
smartmontools \
|
||||||
virtinst \
|
virtinst \
|
||||||
ovmf
|
ovmf
|
||||||
}
|
}
|
||||||
@@ -1040,7 +1042,7 @@ install_dnf() {
|
|||||||
cloud-utils \
|
cloud-utils \
|
||||||
genisoimage
|
genisoimage
|
||||||
|
|
||||||
for pkg in lxcfs xorriso edk2-ovmf; do
|
for pkg in lxcfs xorriso edk2-ovmf smartmontools; do
|
||||||
dnf install -y "$pkg" >/dev/null 2>&1 || warn "可选依赖未安装:$pkg"
|
dnf install -y "$pkg" >/dev/null 2>&1 || warn "可选依赖未安装:$pkg"
|
||||||
done
|
done
|
||||||
}
|
}
|
||||||
@@ -1075,7 +1077,7 @@ install_yum() {
|
|||||||
cloud-utils \
|
cloud-utils \
|
||||||
genisoimage
|
genisoimage
|
||||||
|
|
||||||
for pkg in lxcfs xorriso edk2-ovmf; do
|
for pkg in lxcfs xorriso edk2-ovmf smartmontools; do
|
||||||
yum install -y "$pkg" >/dev/null 2>&1 || warn "可选依赖未安装:$pkg"
|
yum install -y "$pkg" >/dev/null 2>&1 || warn "可选依赖未安装:$pkg"
|
||||||
done
|
done
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user