This commit is contained in:
MengMengCode
2026-06-17 21:06:58 +08:00
parent fd974d95b9
commit 84d98e40c6
12 changed files with 478 additions and 37 deletions
+8 -3
View File
@@ -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
+30 -7
View File
@@ -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 {
+107 -9
View File
@@ -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)
+46
View File
@@ -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)
}
}
+4
View File
@@ -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),
+11 -3
View File
@@ -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)
}
+3
View File
@@ -74,6 +74,9 @@ func (m *Manager) DetectIPv6Status() IPv6Status {
}
func DetectPublicIPv6Prefixes() []IPv6PrefixInfo {
if configured := ConfiguredPublicIPv6Prefixes(); len(configured) > 0 {
return configured
}
return detectPublicIPv6Prefixes(detectIPv6DefaultRoutes())
}
+5 -1
View File
@@ -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)
+6 -6
View File
@@ -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
}
+4 -1
View File
@@ -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.')
+247 -6
View File
@@ -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
}
+7 -1
View File
@@ -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[]) =>