diff --git a/backend/internal/api/handlers.go b/backend/internal/api/handlers.go index 8ff60e7..e07e6ba 100644 --- a/backend/internal/api/handlers.go +++ b/backend/internal/api/handlers.go @@ -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 diff --git a/backend/internal/api/routing.go b/backend/internal/api/routing.go index c480689..86c2766 100644 --- a/backend/internal/api/routing.go +++ b/backend/internal/api/routing.go @@ -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 { diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 3e3d9ce..fd8a437 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -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) diff --git a/backend/internal/config/nat_test.go b/backend/internal/config/nat_test.go new file mode 100644 index 0000000..89141a2 --- /dev/null +++ b/backend/internal/config/nat_test.go @@ -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) + } +} diff --git a/backend/internal/config/store_sqlite.go b/backend/internal/config/store_sqlite.go index 658d5a7..02d337a 100644 --- a/backend/internal/config/store_sqlite.go +++ b/backend/internal/config/store_sqlite.go @@ -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), diff --git a/backend/internal/kvm/kvm.go b/backend/internal/kvm/kvm.go index 9b10a0d..74c7d06 100644 --- a/backend/internal/kvm/kvm.go +++ b/backend/internal/kvm/kvm.go @@ -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) } diff --git a/backend/internal/lxc/ipv6.go b/backend/internal/lxc/ipv6.go index b82226d..ab074ce 100644 --- a/backend/internal/lxc/ipv6.go +++ b/backend/internal/lxc/ipv6.go @@ -74,6 +74,9 @@ func (m *Manager) DetectIPv6Status() IPv6Status { } func DetectPublicIPv6Prefixes() []IPv6PrefixInfo { + if configured := ConfiguredPublicIPv6Prefixes(); len(configured) > 0 { + return configured + } return detectPublicIPv6Prefixes(detectIPv6DefaultRoutes()) } diff --git a/backend/internal/lxc/lxc.go b/backend/internal/lxc/lxc.go index 2b2d136..63bb1df 100644 --- a/backend/internal/lxc/lxc.go +++ b/backend/internal/lxc/lxc.go @@ -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) diff --git a/backend/internal/lxc/portmap.go b/backend/internal/lxc/portmap.go index 39f68f3..716d619 100644 --- a/backend/internal/lxc/portmap.go +++ b/backend/internal/lxc/portmap.go @@ -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 } diff --git a/frontend/src/pages/ApiIntegration.tsx b/frontend/src/pages/ApiIntegration.tsx index 73e0f51..8fceb04 100644 --- a/frontend/src/pages/ApiIntegration.tsx +++ b/frontend/src/pages/ApiIntegration.tsx @@ -914,6 +914,7 @@ const responseSamples: Record = { 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 = { '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.') diff --git a/frontend/src/pages/Routing.tsx b/frontend/src/pages/Routing.tsx index 1a04efb..5473386 100644 --- a/frontend/src/pages/Routing.tsx +++ b/frontend/src/pages/Routing.tsx @@ -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({ 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) => { + 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() {
- + + + + } + />
+ {editingNAT4 && ( + setEditingNAT4(false)}> +
+
+ setNAT4Draft((draft) => ({ ...draft, start: value }))} min={1} max={65535} /> + setNAT4Draft((draft) => ({ ...draft, end: value }))} min={1} max={65535} /> +
+
+ + +
+
+
+ )} + )} - {ipv6Prefixes.length > 0 && ( - + + + {text.editPrefixes} + + } + > + {ipv6Prefixes.length === 0 ? ( + } /> + ) : (
@@ -354,7 +487,58 @@ export default function Routing() {
-
+ )} +
+ + {editingIPv6 && ( + setEditingIPv6(false)} wide> +
+
+ + + + + + + + + + + + {ipv6Draft.map((item, index) => ( + + + + + + + + ))} + {ipv6Draft.length === 0 && } + +
{text.prefix}{text.hostAddress}{text.interface}{text.gateway}{text.action}
updateIPv6Draft(index, { prefix: e.target.value })} placeholder="2001:db8:100::/64" className={smallInputClass} /> updateIPv6Draft(index, { address: e.target.value })} placeholder="2001:db8:100::1" className={smallInputClass} /> updateIPv6Draft(index, { interface: e.target.value })} placeholder={defaultIPv6Interface} className={smallInputClass} /> updateIPv6Draft(index, { gateway: e.target.value })} placeholder={text.gateway} className={smallInputClass} /> + +
+
+
+ +
+ + +
+
+
+
)} }> @@ -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 (
@@ -542,20 +728,46 @@ function CapacityCard({ title, watermark, remaining, total, used, label, usedLab {watermark}
-
+
+
{title}
{remaining} / {total}
+
+ {action}
{label}
{usedLabel} {used}
+ {detail &&
{detail}
}
) } +function LabeledNumberInput({ label, value, onChange, min, max }: { + label: string + value: number + onChange: (value: number) => void + min: number + max: number +}) { + return ( + + ) +} + function EmptyState({ icon, text }: { icon: ReactNode; text: string }) { return (
@@ -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 } diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index b6a8003..1fc2173 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -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>('/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>('/routing', payload) export const updateRoutingIPv4Pool = (items: PublicIPv4Info[]) =>