mirror of
https://github.com/MengMengCode/CLICD.git
synced 2026-08-05 05:36:07 +08:00
FIX #16
This commit is contained in:
@@ -582,9 +582,14 @@ func getRandomPort(w http.ResponseWriter, r *http.Request, id int) {
|
||||
return
|
||||
}
|
||||
hostIP := strings.TrimSpace(r.URL.Query().Get("host_ip"))
|
||||
// Try random ports
|
||||
for tries := 0; tries < 100; tries++ {
|
||||
port := 10000 + (int(time.Now().UnixNano()) % 55535)
|
||||
start, end := config.NATPortRange()
|
||||
capacity := end - start + 1
|
||||
offset := 0
|
||||
if capacity > 0 {
|
||||
offset = int(time.Now().UnixNano() % int64(capacity))
|
||||
}
|
||||
for tries := 0; tries < capacity; tries++ {
|
||||
port := start + ((offset + tries) % capacity)
|
||||
if lxc.HostPortAvailable(c, hostIP, port, "tcp") {
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]int{"port": port}})
|
||||
return
|
||||
|
||||
@@ -17,6 +17,11 @@ type routeCapacity struct {
|
||||
Total string `json:"total"`
|
||||
}
|
||||
|
||||
type nat4PortRange struct {
|
||||
Start int `json:"start"`
|
||||
End int `json:"end"`
|
||||
}
|
||||
|
||||
type nat4Route struct {
|
||||
ContainerID int `json:"container_id"`
|
||||
ContainerName string `json:"container_name"`
|
||||
@@ -53,6 +58,7 @@ type ipv6Route struct {
|
||||
|
||||
type routingResponse struct {
|
||||
NAT4 routeCapacity `json:"nat4"`
|
||||
NAT4PortRange nat4PortRange `json:"nat4_port_range"`
|
||||
IPv4 routeCapacity `json:"ipv4"`
|
||||
IPv6 routeCapacity `json:"ipv6"`
|
||||
HostPublicIPv4 lxc.PublicIPInfo `json:"host_public_ipv4"`
|
||||
@@ -64,9 +70,10 @@ type routingResponse struct {
|
||||
}
|
||||
|
||||
type routingPoolsRequest struct {
|
||||
Addresses *[]string `json:"addresses"`
|
||||
Items *[]config.PublicIPv4Assignment `json:"items"`
|
||||
IPv6Prefixes *[]config.PublicIPv6Prefix `json:"ipv6_prefixes"`
|
||||
Addresses *[]string `json:"addresses"`
|
||||
Items *[]config.PublicIPv4Assignment `json:"items"`
|
||||
IPv6Prefixes *[]config.PublicIPv6Prefix `json:"ipv6_prefixes"`
|
||||
NAT4PortRange *nat4PortRange `json:"nat4_port_range"`
|
||||
}
|
||||
|
||||
type publicIPv4ScanRequest struct {
|
||||
@@ -120,13 +127,12 @@ func handleRoutingGet(w http.ResponseWriter, r *http.Request) {
|
||||
ipv4Assignments := make([]ipv4Route, 0)
|
||||
ipv6Assignments := make([]ipv6Route, 0)
|
||||
|
||||
const nat4StartPort = 20000
|
||||
const nat4EndPort = 65535
|
||||
nat4StartPort, nat4EndPort := config.NATPortRange()
|
||||
|
||||
for i := range config.AppConfig.Containers {
|
||||
c := &config.AppConfig.Containers[i]
|
||||
for _, pm := range c.PortMappings {
|
||||
if pm.HostPort >= nat4StartPort && pm.HostPort <= nat4EndPort {
|
||||
if config.NATPortInRange(pm.HostPort) {
|
||||
usedPorts[pm.HostPort] = true
|
||||
}
|
||||
nat4Mappings = append(nat4Mappings, nat4Route{
|
||||
@@ -189,7 +195,7 @@ func handleRoutingGet(w http.ResponseWriter, r *http.Request) {
|
||||
return ipv6Assignments[i].Address < ipv6Assignments[j].Address
|
||||
})
|
||||
|
||||
const totalNAT4Ports = nat4EndPort - nat4StartPort + 1
|
||||
totalNAT4Ports := config.NATPortCapacity()
|
||||
nat4Used := len(usedPorts)
|
||||
nat4Remaining := totalNAT4Ports - nat4Used
|
||||
if nat4Remaining < 0 {
|
||||
@@ -216,6 +222,10 @@ func handleRoutingGet(w http.ResponseWriter, r *http.Request) {
|
||||
Remaining: strconv.Itoa(nat4Remaining),
|
||||
Total: strconv.Itoa(totalNAT4Ports),
|
||||
},
|
||||
NAT4PortRange: nat4PortRange{
|
||||
Start: nat4StartPort,
|
||||
End: nat4EndPort,
|
||||
},
|
||||
IPv4: routeCapacity{
|
||||
Used: ipv4Used,
|
||||
Remaining: strconv.Itoa(ipv4Remaining),
|
||||
@@ -246,6 +256,19 @@ func handleRoutingPoolsUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if req.NAT4PortRange != nil {
|
||||
start, end, err := config.NormalizeNATPortRange(req.NAT4PortRange.Start, req.NAT4PortRange.End)
|
||||
if err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
config.AppConfig.NATPortStart = start
|
||||
config.AppConfig.NATPortEnd = end
|
||||
if config.AppConfig.NextSSHPort < start || config.AppConfig.NextSSHPort > end {
|
||||
config.AppConfig.NextSSHPort = start
|
||||
}
|
||||
}
|
||||
|
||||
if req.Items != nil || req.Addresses != nil {
|
||||
items := []config.PublicIPv4Assignment{}
|
||||
if req.Items != nil {
|
||||
|
||||
@@ -372,6 +372,8 @@ type ClicdConfig struct {
|
||||
NextContainerID int `json:"next_container_id"`
|
||||
NextVNCPort int `json:"next_vnc_port"`
|
||||
NextSSHPort int `json:"next_ssh_port"`
|
||||
NATPortStart int `json:"nat_port_start"`
|
||||
NATPortEnd int `json:"nat_port_end"`
|
||||
SetupComplete bool `json:"setup_complete"`
|
||||
SubUsers []SubUser `json:"sub_users"`
|
||||
ApiKeys []ApiKeyConfig `json:"api_keys"`
|
||||
@@ -394,6 +396,11 @@ var AppConfig *ClicdConfig
|
||||
|
||||
const DefaultSnapshotLimit = 3
|
||||
|
||||
const (
|
||||
DefaultNATPortStart = 20000
|
||||
DefaultNATPortEnd = 65535
|
||||
)
|
||||
|
||||
func getConfigPath() string {
|
||||
if configPath != "" {
|
||||
return configPath
|
||||
@@ -509,6 +516,8 @@ func InitConfig() (*ClicdConfig, error) {
|
||||
NextContainerID: 1,
|
||||
NextVNCPort: 5900,
|
||||
NextSSHPort: 22000,
|
||||
NATPortStart: DefaultNATPortStart,
|
||||
NATPortEnd: DefaultNATPortEnd,
|
||||
SetupComplete: false,
|
||||
SubUsers: []SubUser{},
|
||||
AuditLogs: []AuditLog{},
|
||||
@@ -552,6 +561,9 @@ func normalizeConfigDefaults(dataDir string) bool {
|
||||
AppConfig.NextSSHPort = 22000
|
||||
changed = true
|
||||
}
|
||||
if normalizeNATPortRangeDefaults() {
|
||||
changed = true
|
||||
}
|
||||
if AppConfig.NextContainerID == 0 {
|
||||
AppConfig.NextContainerID = 1
|
||||
changed = true
|
||||
@@ -1168,16 +1180,102 @@ func UpdateVNC(containers []Container) {
|
||||
SaveConfig()
|
||||
}
|
||||
|
||||
// AllocateSSHPort allocates a new SSH port, skipping ports already used by any container
|
||||
func AllocateSSHPort() int {
|
||||
used := collectAllHostPorts()
|
||||
port := AppConfig.NextSSHPort
|
||||
for used[port] {
|
||||
port++
|
||||
func NormalizeNATPortRange(start, end int) (int, int, error) {
|
||||
if start == 0 && end == 0 {
|
||||
return DefaultNATPortStart, DefaultNATPortEnd, nil
|
||||
}
|
||||
AppConfig.NextSSHPort = port + 1
|
||||
SaveConfig()
|
||||
return port
|
||||
if start == 0 {
|
||||
start = DefaultNATPortStart
|
||||
}
|
||||
if end == 0 {
|
||||
end = DefaultNATPortEnd
|
||||
}
|
||||
if start < 1 || start > 65535 {
|
||||
return 0, 0, fmt.Errorf("NAT port start must be 1-65535")
|
||||
}
|
||||
if end < 1 || end > 65535 {
|
||||
return 0, 0, fmt.Errorf("NAT port end must be 1-65535")
|
||||
}
|
||||
if start > end {
|
||||
return 0, 0, fmt.Errorf("NAT port start cannot be greater than end")
|
||||
}
|
||||
return start, end, nil
|
||||
}
|
||||
|
||||
func NATPortRange() (int, int) {
|
||||
if AppConfig == nil {
|
||||
return DefaultNATPortStart, DefaultNATPortEnd
|
||||
}
|
||||
start, end, err := NormalizeNATPortRange(AppConfig.NATPortStart, AppConfig.NATPortEnd)
|
||||
if err != nil {
|
||||
return DefaultNATPortStart, DefaultNATPortEnd
|
||||
}
|
||||
return start, end
|
||||
}
|
||||
|
||||
func NATPortCapacity() int {
|
||||
start, end := NATPortRange()
|
||||
return end - start + 1
|
||||
}
|
||||
|
||||
func NATPortInRange(port int) bool {
|
||||
start, end := NATPortRange()
|
||||
return port >= start && port <= end
|
||||
}
|
||||
|
||||
func SetNATPortRange(start, end int) error {
|
||||
start, end, err := NormalizeNATPortRange(start, end)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
AppConfig.NATPortStart = start
|
||||
AppConfig.NATPortEnd = end
|
||||
if AppConfig.NextSSHPort < start || AppConfig.NextSSHPort > end {
|
||||
AppConfig.NextSSHPort = start
|
||||
}
|
||||
return SaveConfig()
|
||||
}
|
||||
|
||||
func normalizeNATPortRangeDefaults() bool {
|
||||
if AppConfig == nil {
|
||||
return false
|
||||
}
|
||||
start, end, err := NormalizeNATPortRange(AppConfig.NATPortStart, AppConfig.NATPortEnd)
|
||||
if err != nil {
|
||||
start, end = DefaultNATPortStart, DefaultNATPortEnd
|
||||
}
|
||||
changed := AppConfig.NATPortStart != start || AppConfig.NATPortEnd != end
|
||||
AppConfig.NATPortStart = start
|
||||
AppConfig.NATPortEnd = end
|
||||
if AppConfig.NextSSHPort < start || AppConfig.NextSSHPort > end {
|
||||
AppConfig.NextSSHPort = start
|
||||
changed = true
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
// AllocateSSHPort allocates a new SSH port, skipping ports already used by any container
|
||||
func AllocateSSHPort() (int, error) {
|
||||
used := collectAllHostPorts()
|
||||
start, end := NATPortRange()
|
||||
port := AppConfig.NextSSHPort
|
||||
if port < start || port > end {
|
||||
port = start
|
||||
}
|
||||
capacity := end - start + 1
|
||||
for i := 0; i < capacity; i++ {
|
||||
candidate := start + ((port - start + i) % capacity)
|
||||
if used[candidate] {
|
||||
continue
|
||||
}
|
||||
AppConfig.NextSSHPort = candidate + 1
|
||||
if AppConfig.NextSSHPort > end {
|
||||
AppConfig.NextSSHPort = start
|
||||
}
|
||||
SaveConfig()
|
||||
return candidate, nil
|
||||
}
|
||||
return 0, fmt.Errorf("no free NAT4 host port in configured range %d-%d", start, end)
|
||||
}
|
||||
|
||||
// collectAllHostPorts collects all host ports used by any container (LXC + KVM)
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package config
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestAllocateSSHPortUsesConfiguredNATRange(t *testing.T) {
|
||||
AppConfig = &ClicdConfig{
|
||||
NATPortStart: 30000,
|
||||
NATPortEnd: 30002,
|
||||
NextSSHPort: 22000,
|
||||
Containers: []Container{{
|
||||
PortMappings: []PortMapping{
|
||||
{HostPort: 30000},
|
||||
{HostPort: 30001},
|
||||
},
|
||||
}},
|
||||
}
|
||||
|
||||
port, err := AllocateSSHPort()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if port != 30002 {
|
||||
t.Fatalf("expected port 30002, got %d", port)
|
||||
}
|
||||
if AppConfig.NextSSHPort != 30000 {
|
||||
t.Fatalf("expected next port to wrap to 30000, got %d", AppConfig.NextSSHPort)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllocateSSHPortErrorsWhenConfiguredRangeIsFull(t *testing.T) {
|
||||
AppConfig = &ClicdConfig{
|
||||
NATPortStart: 31000,
|
||||
NATPortEnd: 31001,
|
||||
NextSSHPort: 31000,
|
||||
Containers: []Container{{
|
||||
PortMappings: []PortMapping{
|
||||
{HostPort: 31000},
|
||||
{HostPort: 31001},
|
||||
},
|
||||
}},
|
||||
}
|
||||
|
||||
if port, err := AllocateSSHPort(); err == nil {
|
||||
t.Fatalf("expected exhausted NAT range error, got port %d", port)
|
||||
}
|
||||
}
|
||||
@@ -524,6 +524,8 @@ func loadConfigFromDB() (*ClicdConfig, bool, error) {
|
||||
NextContainerID: atoi(meta["next_container_id"]),
|
||||
NextVNCPort: atoi(meta["next_vnc_port"]),
|
||||
NextSSHPort: atoi(meta["next_ssh_port"]),
|
||||
NATPortStart: atoi(meta["nat_port_start"]),
|
||||
NATPortEnd: atoi(meta["nat_port_end"]),
|
||||
SetupComplete: atob(meta["setup_complete"]),
|
||||
SecurityAutoShutdown: atob(meta["security_auto_shutdown"]),
|
||||
Language: meta["language"],
|
||||
@@ -651,6 +653,8 @@ func saveMeta(tx *sql.Tx) error {
|
||||
"next_container_id": strconv.Itoa(AppConfig.NextContainerID),
|
||||
"next_vnc_port": strconv.Itoa(AppConfig.NextVNCPort),
|
||||
"next_ssh_port": strconv.Itoa(AppConfig.NextSSHPort),
|
||||
"nat_port_start": strconv.Itoa(AppConfig.NATPortStart),
|
||||
"nat_port_end": strconv.Itoa(AppConfig.NATPortEnd),
|
||||
"setup_complete": btoa(AppConfig.SetupComplete),
|
||||
"security_auto_shutdown": btoa(AppConfig.SecurityAutoShutdown),
|
||||
"language": NormalizeLanguage(AppConfig.Language),
|
||||
|
||||
@@ -487,7 +487,10 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig
|
||||
sshPort := 0
|
||||
portMappings := []config.PortMapping{}
|
||||
if allocatePorts && cfg.WantsNAT() {
|
||||
sshPort = config.AllocateSSHPort()
|
||||
sshPort, err = config.AllocateSSHPort()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if IsWindowsImage(image.ID) {
|
||||
// Windows: RDP (3389) instead of SSH (22)
|
||||
portMappings = []config.PortMapping{{
|
||||
@@ -2406,7 +2409,11 @@ func normalizeKVMManagementPortMapping(c *config.Container) {
|
||||
}
|
||||
hostPort := c.SSHPort
|
||||
if hostPort <= 0 {
|
||||
hostPort = config.AllocateSSHPort()
|
||||
allocated, err := config.AllocateSSHPort()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
hostPort = allocated
|
||||
c.SSHPort = hostPort
|
||||
}
|
||||
desiredPort := 22
|
||||
@@ -3908,7 +3915,8 @@ func allocateDefaultEqualPorts(c *config.Container, count int) []int {
|
||||
}
|
||||
}
|
||||
ports := make([]int, 0, count)
|
||||
for next := 20000; next <= 65535 && len(ports) < count; next++ {
|
||||
start, end := config.NATPortRange()
|
||||
for next := start; next <= end && len(ports) < count; next++ {
|
||||
if !used[next] {
|
||||
ports = append(ports, next)
|
||||
}
|
||||
|
||||
@@ -74,6 +74,9 @@ func (m *Manager) DetectIPv6Status() IPv6Status {
|
||||
}
|
||||
|
||||
func DetectPublicIPv6Prefixes() []IPv6PrefixInfo {
|
||||
if configured := ConfiguredPublicIPv6Prefixes(); len(configured) > 0 {
|
||||
return configured
|
||||
}
|
||||
return detectPublicIPv6Prefixes(detectIPv6DefaultRoutes())
|
||||
}
|
||||
|
||||
|
||||
@@ -381,7 +381,11 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
|
||||
sshPort := 0
|
||||
portMappings := []config.PortMapping{}
|
||||
if cfg.WantsNAT() {
|
||||
sshPort = config.AllocateSSHPort()
|
||||
sshPort, err = config.AllocateSSHPort()
|
||||
if err != nil {
|
||||
_ = m.cleanupContainerStorage(lxcName)
|
||||
return err
|
||||
}
|
||||
|
||||
// Setup default port mappings (SSH only)
|
||||
portMappings = SetupDefaultPortMappings(sshPort)
|
||||
|
||||
@@ -395,6 +395,10 @@ func normalizePortMapping(c *config.Container, skipIndex int, pm config.PortMapp
|
||||
if pm.HostPort <= 0 {
|
||||
pm.HostPort = pm.ContainerPort
|
||||
}
|
||||
if pm.HostIP == "" && !config.NATPortInRange(pm.HostPort) {
|
||||
start, end := config.NATPortRange()
|
||||
return pm, fmt.Errorf("host port must be within configured NAT4 range %d-%d", start, end)
|
||||
}
|
||||
// Check current container's own mappings
|
||||
for i, existing := range c.PortMappings {
|
||||
if i == skipIndex {
|
||||
@@ -444,16 +448,12 @@ func allocateDefaultEqualPorts(c *config.Container, count int) []int {
|
||||
}
|
||||
}
|
||||
ports := make([]int, 0, count)
|
||||
next := 20000
|
||||
for len(ports) < count {
|
||||
start, end := config.NATPortRange()
|
||||
for next := start; next <= end && len(ports) < count; next++ {
|
||||
hostIP := c.PrimaryPublicIPv4()
|
||||
if !used[hostPortKey(hostIP, next)] && !used[next] {
|
||||
ports = append(ports, next)
|
||||
}
|
||||
next++
|
||||
if next > 65535 || len(ports) >= count {
|
||||
break
|
||||
}
|
||||
}
|
||||
return ports
|
||||
}
|
||||
|
||||
@@ -914,6 +914,7 @@ const responseSamples: Record<string, unknown> = {
|
||||
success: true,
|
||||
data: {
|
||||
nat4: { used: 62, remaining: '45474', total: '45536' },
|
||||
nat4_port_range: { start: 20000, end: 65535 },
|
||||
ipv4: { used: 1, remaining: '3', total: '4' },
|
||||
ipv6: { used: 31, remaining: 'large', total: 'large' },
|
||||
public_ipv4_addresses: [{ address: '203.0.113.10', interface: 'eth0', prefix_len: 32, gateway: '203.0.113.1' }],
|
||||
@@ -927,6 +928,8 @@ const responseSamples: Record<string, unknown> = {
|
||||
'PUT /api/v1/routing': {
|
||||
success: true,
|
||||
data: {
|
||||
nat4: { used: 62, remaining: '45474', total: '45536' },
|
||||
nat4_port_range: { start: 20000, end: 65535 },
|
||||
ipv4: { used: 1, remaining: '3', total: '4' },
|
||||
public_ipv4_addresses: [{ address: '203.0.113.10', interface: 'eth0', prefix_len: 32, gateway: '203.0.113.1' }],
|
||||
ipv6_prefixes: [{ interface: 'eth0', address: '2001:db8:100::2', prefix: '2001:db8:100::/64', prefix_len: 64, gateway: '2001:db8:100::1' }],
|
||||
@@ -1196,7 +1199,7 @@ function endpointNoteFor(key: string) {
|
||||
notes.push('When action=reinstall, you can include template_id, ssh_auth_mode, ssh_password, and ssh_public_key. Other actions ignore these reinstall fields.')
|
||||
}
|
||||
if (key === 'PUT /api/v1/routing') {
|
||||
notes.push('Updating public address pools requires routing:write. Addresses already assigned to containers cannot be removed from the pool.')
|
||||
notes.push('Updating NAT4 port range and public address pools requires routing:write. Addresses already assigned to containers cannot be removed from the pool.')
|
||||
}
|
||||
if (key === 'POST /api/v1/routing/ipv4-scan') {
|
||||
notes.push('Scanning public IPv4 prefixes requires routing:write. When verify=true, the API also attempts to check address availability.')
|
||||
|
||||
@@ -4,9 +4,13 @@ import { useNavigate } from 'react-router-dom'
|
||||
import { useLanguage, type Language } from '../contexts/LanguageContext'
|
||||
import {
|
||||
getRoutingInfo,
|
||||
updateRoutingIPv6Prefixes,
|
||||
updateRoutingIPv4Pool,
|
||||
updateRoutingPools,
|
||||
type IPv4Route,
|
||||
type IPv6Route,
|
||||
type IPv6PrefixInfo,
|
||||
type NAT4PortRange,
|
||||
type NAT4Route,
|
||||
type PublicIPv4Info,
|
||||
type RoutingInfo,
|
||||
@@ -25,6 +29,12 @@ export default function Routing() {
|
||||
const [savingIPv4, setSavingIPv4] = useState(false)
|
||||
const [ipv4Draft, setIPv4Draft] = useState<(PublicIPv4Info & { _id: number })[]>([])
|
||||
const nextDraftId = useRef(0)
|
||||
const [editingNAT4, setEditingNAT4] = useState(false)
|
||||
const [savingNAT4, setSavingNAT4] = useState(false)
|
||||
const [nat4Draft, setNAT4Draft] = useState<NAT4PortRange>({ start: 20000, end: 65535 })
|
||||
const [editingIPv6, setEditingIPv6] = useState(false)
|
||||
const [savingIPv6, setSavingIPv6] = useState(false)
|
||||
const [ipv6Draft, setIPv6Draft] = useState<(IPv6PrefixInfo & { _id: number })[]>([])
|
||||
const [nat4Page, setNat4Page] = useState(1)
|
||||
const [ipv6Page, setIPv6Page] = useState(1)
|
||||
const [nat4Search, setNat4Search] = useState('')
|
||||
@@ -49,9 +59,12 @@ export default function Routing() {
|
||||
const nat4Mappings = routing?.nat4_mappings || []
|
||||
const ipv6Prefixes = routing?.ipv6_prefixes || []
|
||||
const ipv6Assignments = routing?.ipv6_assignments || []
|
||||
const nat4Range = routing?.nat4_port_range || { start: 20000, end: 65535 }
|
||||
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
|
||||
const defaultIPv6Interface = ipv6Prefixes[0]?.interface || defaultIPv4Interface
|
||||
const defaultIPv6Gateway = ipv6Prefixes[0]?.gateway || ''
|
||||
|
||||
useEffect(() => {
|
||||
if (!editingIPv4) {
|
||||
@@ -139,6 +152,81 @@ export default function Routing() {
|
||||
}
|
||||
}
|
||||
|
||||
const startEditNAT4 = () => {
|
||||
setNAT4Draft({ start: nat4Range.start || 20000, end: nat4Range.end || 65535 })
|
||||
setEditingNAT4(true)
|
||||
}
|
||||
|
||||
const saveNAT4Range = async () => {
|
||||
const start = Math.round(Number(nat4Draft.start || 0))
|
||||
const end = Math.round(Number(nat4Draft.end || 0))
|
||||
if (start < 1 || start > 65535 || end < 1 || end > 65535 || start > end) {
|
||||
alert(text.nat4RangeInvalid)
|
||||
return
|
||||
}
|
||||
setSavingNAT4(true)
|
||||
try {
|
||||
const res = await updateRoutingPools({ nat4_port_range: { start, end } })
|
||||
setRouting(res.data.data || null)
|
||||
setEditingNAT4(false)
|
||||
} catch (err: any) {
|
||||
alert(err?.response?.data?.message || text.saveNAT4RangeFailed)
|
||||
} finally {
|
||||
setSavingNAT4(false)
|
||||
}
|
||||
}
|
||||
|
||||
const startEditIPv6 = () => {
|
||||
setIPv6Draft(ipv6Prefixes.map((prefix) => ({ ...prefix, _id: nextDraftId.current++ })))
|
||||
setEditingIPv6(true)
|
||||
}
|
||||
|
||||
const addIPv6Row = () => {
|
||||
setIPv6Draft((items) => [
|
||||
...items,
|
||||
{
|
||||
_id: nextDraftId.current++,
|
||||
prefix: '',
|
||||
address: '',
|
||||
prefix_len: 64,
|
||||
interface: defaultIPv6Interface,
|
||||
gateway: defaultIPv6Gateway,
|
||||
source: 'manual',
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
const updateIPv6Draft = (index: number, patch: Partial<IPv6PrefixInfo>) => {
|
||||
setIPv6Draft((items) => items.map((item, i) => (i === index ? { ...item, ...patch } : item)))
|
||||
}
|
||||
|
||||
const saveIPv6Prefixes = async () => {
|
||||
setSavingIPv6(true)
|
||||
try {
|
||||
const items = ipv6Draft
|
||||
.map(({ _id, ...item }) => ({
|
||||
...item,
|
||||
prefix: (item.prefix || '').trim(),
|
||||
address: (item.address || '').trim(),
|
||||
interface: (item.interface || defaultIPv6Interface).trim(),
|
||||
gateway: (item.gateway || '').trim(),
|
||||
prefix_len: Number(item.prefix_len || 0),
|
||||
}))
|
||||
.filter((item) => item.prefix || item.address)
|
||||
if (items.some((item) => !item.interface)) {
|
||||
alert(text.ipv6InterfaceRequired)
|
||||
return
|
||||
}
|
||||
const res = await updateRoutingIPv6Prefixes(items)
|
||||
setRouting(res.data.data || null)
|
||||
setEditingIPv6(false)
|
||||
} catch (err: any) {
|
||||
alert(err?.response?.data?.message || text.saveIPv6PrefixesFailed)
|
||||
} finally {
|
||||
setSavingIPv6(false)
|
||||
}
|
||||
}
|
||||
|
||||
const filteredNat4 = useMemo(() => {
|
||||
const q = nat4Search.toLowerCase().trim()
|
||||
if (!q) return nat4Mappings
|
||||
@@ -189,11 +277,45 @@ export default function Routing() {
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<CapacityCard title={text.nat4Ports} watermark="NAT4" remaining={routing?.nat4.remaining || '0'} total={routing?.nat4.total || '0'} used={routing?.nat4.used || 0} label={text.remainingTotal} usedLabel={text.used} />
|
||||
<CapacityCard
|
||||
title={text.nat4Ports}
|
||||
watermark="NAT4"
|
||||
remaining={routing?.nat4.remaining || '0'}
|
||||
total={routing?.nat4.total || '0'}
|
||||
used={routing?.nat4.used || 0}
|
||||
label={text.remainingTotal}
|
||||
usedLabel={text.used}
|
||||
detail={formatNATRange(nat4Range, language)}
|
||||
action={
|
||||
<button onClick={startEditNAT4} className="rounded p-1.5 text-gray-500 hover:bg-gray-100 hover:text-black" title={text.editNAT4Range}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<CapacityCard title={text.publicIPv4} watermark="IPv4" remaining={routing?.ipv4.remaining || '0'} total={routing?.ipv4.total || '0'} used={routing?.ipv4.used || 0} label={formatPoolCount(publicIPv4s.length, language)} usedLabel={text.used} />
|
||||
<CapacityCard title="IPv6" watermark="IPv6" 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} />
|
||||
</div>
|
||||
|
||||
{editingNAT4 && (
|
||||
<RouteModal title={text.editNAT4Range} onClose={() => setEditingNAT4(false)}>
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<LabeledNumberInput label={text.rangeStart} value={nat4Draft.start} onChange={(value) => setNAT4Draft((draft) => ({ ...draft, start: value }))} min={1} max={65535} />
|
||||
<LabeledNumberInput label={text.rangeEnd} value={nat4Draft.end} onChange={(value) => setNAT4Draft((draft) => ({ ...draft, end: value }))} min={1} max={65535} />
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button onClick={() => setEditingNAT4(false)} disabled={savingNAT4} 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={saveNAT4Range} disabled={savingNAT4} 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" />
|
||||
{savingNAT4 ? text.saving : text.save}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</RouteModal>
|
||||
)}
|
||||
|
||||
<Panel
|
||||
title={text.publicIPv4Pool}
|
||||
subtitle={formatIPv4PoolSubtitle(publicIPv4s.length, ipv4Assignments.length, language)}
|
||||
@@ -328,8 +450,19 @@ export default function Routing() {
|
||||
</RouteModal>
|
||||
)}
|
||||
|
||||
{ipv6Prefixes.length > 0 && (
|
||||
<Panel title={text.detectedIPv6Prefixes} subtitle={formatPrefixCount(ipv6Prefixes.length, language)}>
|
||||
<Panel
|
||||
title={text.detectedIPv6Prefixes}
|
||||
subtitle={formatPrefixCount(ipv6Prefixes.length, language)}
|
||||
action={
|
||||
<button onClick={startEditIPv6} 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">
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
{text.editPrefixes}
|
||||
</button>
|
||||
}
|
||||
>
|
||||
{ipv6Prefixes.length === 0 ? (
|
||||
<EmptyState text={text.noIPv6Prefixes} icon={<Router className="h-7 w-7" />} />
|
||||
) : (
|
||||
<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">
|
||||
@@ -354,7 +487,58 @@ export default function Routing() {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Panel>
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
{editingIPv6 && (
|
||||
<RouteModal title={text.editIPv6Prefixes} onClose={() => setEditingIPv6(false)} wide>
|
||||
<div className="space-y-3">
|
||||
<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.prefix}</th>
|
||||
<th className="px-3 py-2 text-left font-medium">{text.hostAddress}</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.gateway}</th>
|
||||
<th className="px-3 py-2 text-right font-medium">{text.action}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{ipv6Draft.map((item, index) => (
|
||||
<tr key={item._id}>
|
||||
<td className="px-3 py-2"><input value={item.prefix || ''} onChange={(e) => updateIPv6Draft(index, { prefix: e.target.value })} placeholder="2001:db8:100::/64" className={smallInputClass} /></td>
|
||||
<td className="px-3 py-2"><input value={item.address || ''} onChange={(e) => updateIPv6Draft(index, { address: e.target.value })} placeholder="2001:db8:100::1" className={smallInputClass} /></td>
|
||||
<td className="px-3 py-2"><input value={item.interface || ''} onChange={(e) => updateIPv6Draft(index, { interface: e.target.value })} placeholder={defaultIPv6Interface} className={smallInputClass} /></td>
|
||||
<td className="px-3 py-2"><input value={item.gateway || ''} onChange={(e) => updateIPv6Draft(index, { gateway: e.target.value })} placeholder={text.gateway} className={smallInputClass} /></td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<button onClick={() => setIPv6Draft((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>
|
||||
))}
|
||||
{ipv6Draft.length === 0 && <EmptyRow colSpan={5} text={text.noIPv6Prefixes} />}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<button onClick={addIPv6Row} 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.addIPv6Prefix}
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={() => setEditingIPv6(false)} disabled={savingIPv6} 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={saveIPv6Prefixes} disabled={savingIPv6} 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" />
|
||||
{savingIPv6 ? text.saving : text.save}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</RouteModal>
|
||||
)}
|
||||
|
||||
<Panel title={text.ipv4NAT} subtitle={formatMappingSubtitle(filteredNat4.length, nat4Mappings.length, language)} action={<SearchBox value={nat4Search} onChange={setNat4Search} placeholder={text.searchNAT} />}>
|
||||
@@ -527,7 +711,7 @@ function Pagination({ page, totalPages, totalItems, pageSize, onPageChange, lang
|
||||
)
|
||||
}
|
||||
|
||||
function CapacityCard({ title, watermark, remaining, total, used, label, usedLabel }: {
|
||||
function CapacityCard({ title, watermark, remaining, total, used, label, usedLabel, detail, action }: {
|
||||
title: string
|
||||
watermark: string
|
||||
remaining: string
|
||||
@@ -535,6 +719,8 @@ function CapacityCard({ title, watermark, remaining, total, used, label, usedLab
|
||||
used: number
|
||||
label: string
|
||||
usedLabel: string
|
||||
detail?: string
|
||||
action?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="relative overflow-hidden rounded-lg border border-gray-200 bg-white p-4">
|
||||
@@ -542,20 +728,46 @@ function CapacityCard({ title, watermark, remaining, total, used, label, usedLab
|
||||
{watermark}
|
||||
</div>
|
||||
<div className="relative z-10">
|
||||
<div>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-700">{title}</div>
|
||||
<div className="mt-2 flex items-end gap-2">
|
||||
<span className="text-2xl font-semibold text-black">{remaining}</span>
|
||||
<span className="pb-1 text-sm text-gray-400">/ {total}</span>
|
||||
</div>
|
||||
</div>
|
||||
{action}
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative z-10 mt-3 text-xs text-gray-500">{label}</div>
|
||||
<div className="relative z-10 mt-1 text-xs text-gray-400">{usedLabel} {used}</div>
|
||||
{detail && <div className="relative z-10 mt-1 font-mono text-xs text-gray-400">{detail}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LabeledNumberInput({ label, value, onChange, min, max }: {
|
||||
label: string
|
||||
value: number
|
||||
onChange: (value: number) => void
|
||||
min: number
|
||||
max: number
|
||||
}) {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-xs font-medium text-gray-500">{label}</span>
|
||||
<input
|
||||
type="number"
|
||||
min={min}
|
||||
max={max}
|
||||
value={value || ''}
|
||||
onChange={(event) => onChange(Number(event.target.value))}
|
||||
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-800 focus:outline-none focus:ring-1 focus:ring-black"
|
||||
/>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyState({ icon, text }: { icon: ReactNode; text: string }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center px-6 py-16 text-center">
|
||||
@@ -632,6 +844,11 @@ const routingText = {
|
||||
pageSubtitle: 'NAT4、公网 IPv4 池和 IPv6 地址分配',
|
||||
refresh: '刷新',
|
||||
nat4Ports: 'NAT4 端口',
|
||||
editNAT4Range: '编辑 NAT4 范围',
|
||||
rangeStart: '起始端口',
|
||||
rangeEnd: '结束端口',
|
||||
nat4RangeInvalid: 'NAT4 范围必须是 1-65535,且起始端口不能大于结束端口',
|
||||
saveNAT4RangeFailed: '保存 NAT4 范围失败',
|
||||
remainingTotal: '剩余 / 总数',
|
||||
publicIPv4: '公网 IPv4',
|
||||
publicIPv4Pool: '公网 IPv4 池',
|
||||
@@ -661,10 +878,17 @@ const routingText = {
|
||||
save: '保存',
|
||||
saving: '保存中...',
|
||||
detectedIPv6Prefixes: '检测到的 IPv6 前缀',
|
||||
editPrefixes: '编辑前缀',
|
||||
editIPv6Prefixes: '编辑 IPv6 前缀',
|
||||
addIPv6Prefix: '添加 IPv6 前缀',
|
||||
noIPv6Prefixes: '暂无 IPv6 前缀',
|
||||
ipv6InterfaceRequired: 'IPv6 网卡不能为空',
|
||||
saveIPv6PrefixesFailed: '保存 IPv6 前缀失败',
|
||||
prefix: '前缀',
|
||||
hostAddress: '宿主地址',
|
||||
source: '来源',
|
||||
local: '本机',
|
||||
manual: '手动',
|
||||
ipv4NAT: 'IPv4 NAT',
|
||||
searchNAT: '搜索 NAT...',
|
||||
noIPv4NATMappings: '暂无 IPv4 NAT 映射',
|
||||
@@ -691,6 +915,11 @@ const routingText = {
|
||||
pageSubtitle: 'NAT4, public IPv4 pool, and IPv6 assignments',
|
||||
refresh: 'Refresh',
|
||||
nat4Ports: 'NAT4 ports',
|
||||
editNAT4Range: 'Edit NAT4 range',
|
||||
rangeStart: 'Start port',
|
||||
rangeEnd: 'End port',
|
||||
nat4RangeInvalid: 'NAT4 range must be 1-65535, and start cannot be greater than end',
|
||||
saveNAT4RangeFailed: 'Save NAT4 range failed',
|
||||
remainingTotal: 'remaining / total',
|
||||
publicIPv4: 'Public IPv4',
|
||||
publicIPv4Pool: 'Public IPv4 pool',
|
||||
@@ -720,10 +949,17 @@ const routingText = {
|
||||
save: 'Save',
|
||||
saving: 'Saving...',
|
||||
detectedIPv6Prefixes: 'Detected IPv6 prefixes',
|
||||
editPrefixes: 'Edit prefixes',
|
||||
editIPv6Prefixes: 'Edit IPv6 prefixes',
|
||||
addIPv6Prefix: 'Add IPv6 prefix',
|
||||
noIPv6Prefixes: 'No IPv6 prefixes',
|
||||
ipv6InterfaceRequired: 'IPv6 interface is required',
|
||||
saveIPv6PrefixesFailed: 'Save IPv6 prefixes failed',
|
||||
prefix: 'Prefix',
|
||||
hostAddress: 'Host address',
|
||||
source: 'Source',
|
||||
local: 'local',
|
||||
manual: 'manual',
|
||||
ipv4NAT: 'IPv4 NAT',
|
||||
searchNAT: 'Search NAT...',
|
||||
noIPv4NATMappings: 'No IPv4 NAT mappings',
|
||||
@@ -763,6 +999,10 @@ function formatDetectedPrefixCount(count: number, language: Language) {
|
||||
: `检测到 ${count} 个前缀`
|
||||
}
|
||||
|
||||
function formatNATRange(range: NAT4PortRange, language: Language) {
|
||||
return language === 'en' ? `range ${range.start}-${range.end}` : `范围 ${range.start}-${range.end}`
|
||||
}
|
||||
|
||||
function formatPrefixCount(count: number, language: Language) {
|
||||
return language === 'en' ? `${count} ${count === 1 ? 'prefix' : 'prefixes'}` : `${count} 个前缀`
|
||||
}
|
||||
@@ -801,6 +1041,7 @@ function formatContainerStatus(status: string, language: Language) {
|
||||
|
||||
function formatSource(source: string | undefined, language: Language) {
|
||||
if (!source || source === 'local') return routingText[language].local
|
||||
if (source === 'manual') return routingText[language].manual
|
||||
return source
|
||||
}
|
||||
|
||||
|
||||
@@ -535,6 +535,11 @@ export interface RouteCapacity {
|
||||
total: string
|
||||
}
|
||||
|
||||
export interface NAT4PortRange {
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
export interface NAT4Route {
|
||||
container_id: number
|
||||
container_name: string
|
||||
@@ -571,6 +576,7 @@ export interface IPv6Route {
|
||||
|
||||
export interface RoutingInfo {
|
||||
nat4: RouteCapacity
|
||||
nat4_port_range: NAT4PortRange
|
||||
ipv4: RouteCapacity
|
||||
ipv6: RouteCapacity
|
||||
host_public_ipv4?: PublicIPv4Info
|
||||
@@ -590,7 +596,7 @@ export interface PublicIPv4ScanResult extends PublicIPv4Info {
|
||||
export const getRoutingInfo = () =>
|
||||
api.get<APIResponse<RoutingInfo>>('/routing')
|
||||
|
||||
export const updateRoutingPools = (payload: { items?: PublicIPv4Info[]; ipv6_prefixes?: IPv6PrefixInfo[] }) =>
|
||||
export const updateRoutingPools = (payload: { items?: PublicIPv4Info[]; ipv6_prefixes?: IPv6PrefixInfo[]; nat4_port_range?: NAT4PortRange }) =>
|
||||
api.put<APIResponse<RoutingInfo>>('/routing', payload)
|
||||
|
||||
export const updateRoutingIPv4Pool = (items: PublicIPv4Info[]) =>
|
||||
|
||||
Reference in New Issue
Block a user