From 8b402fcfa1b11ffe93c7c4151c9d68047ba9a997 Mon Sep 17 00:00:00 2001 From: MengMengCode <227010654+MengMengCode@users.noreply.github.com> Date: Sat, 6 Jun 2026 10:30:48 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E7=AE=A1=E7=90=86=E5=91=98?= =?UTF-8?q?=E7=AB=AF=E8=B7=AF=E7=94=B1=E7=AE=A1=E7=90=86=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=EF=BC=8C=E6=9F=A5=E7=9C=8B=E5=BD=93=E5=89=8D=E7=B3=BB=E7=BB=9F?= =?UTF-8?q?=E5=89=A9=E4=BD=99=E7=9A=84NAT4=E6=88=96=E8=80=85V6=E5=9C=B0?= =?UTF-8?q?=E5=9D=80=E5=89=A9=E4=BD=99=E6=83=85=E5=86=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/api/routing.go | 151 +++++++++++++++ backend/internal/lxc/ipv6.go | 21 ++ backend/internal/lxc/lxc.go | 2 +- backend/internal/server/server.go | 1 + frontend/src/App.tsx | 2 + frontend/src/components/Sidebar.tsx | 14 ++ frontend/src/pages/Routing.tsx | 289 ++++++++++++++++++++++++++++ frontend/src/services/api.ts | 39 ++++ 8 files changed, 518 insertions(+), 1 deletion(-) create mode 100644 backend/internal/api/routing.go create mode 100644 frontend/src/pages/Routing.tsx diff --git a/backend/internal/api/routing.go b/backend/internal/api/routing.go new file mode 100644 index 0000000..c48a8be --- /dev/null +++ b/backend/internal/api/routing.go @@ -0,0 +1,151 @@ +package api + +import ( + "net/http" + "sort" + "strconv" + + "clicd/internal/config" + "clicd/internal/lxc" +) + +type routeCapacity struct { + Used int `json:"used"` + Remaining string `json:"remaining"` + Total string `json:"total"` +} + +type nat4Route struct { + ContainerID int `json:"container_id"` + ContainerName string `json:"container_name"` + LXCName string `json:"lxc_name"` + Status string `json:"status"` + IP string `json:"ip"` + HostPort int `json:"host_port"` + ContainerPort int `json:"container_port"` + Protocol string `json:"protocol"` + Description string `json:"description"` +} + +type ipv6Route struct { + ContainerID int `json:"container_id"` + ContainerName string `json:"container_name"` + LXCName string `json:"lxc_name"` + Status string `json:"status"` + Address string `json:"address"` + PrefixLen int `json:"prefix_len"` + Interface string `json:"interface"` +} + +type routingResponse struct { + NAT4 routeCapacity `json:"nat4"` + IPv6 routeCapacity `json:"ipv6"` + NAT4Mappings []nat4Route `json:"nat4_mappings"` + IPv6Assignments []ipv6Route `json:"ipv6_assignments"` + IPv6Prefixes []lxc.IPv6PrefixInfo `json:"ipv6_prefixes"` +} + +func HandleRouting(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) + return + } + + nat4Mappings := make([]nat4Route, 0) + usedPorts := map[int]bool{} + ipv6Assignments := make([]ipv6Route, 0) + + const nat4StartPort = 20000 + const nat4EndPort = 65535 + + for _, c := range config.AppConfig.Containers { + for _, pm := range c.PortMappings { + if pm.HostPort >= nat4StartPort && pm.HostPort <= nat4EndPort { + usedPorts[pm.HostPort] = true + } + nat4Mappings = append(nat4Mappings, nat4Route{ + ContainerID: c.ID, + ContainerName: c.Name, + LXCName: c.LxcName(), + Status: c.Status, + IP: c.IP, + HostPort: pm.HostPort, + ContainerPort: pm.ContainerPort, + Protocol: pm.Protocol, + Description: pm.Description, + }) + } + if c.IPv6 != "" { + ipv6Assignments = append(ipv6Assignments, ipv6Route{ + ContainerID: c.ID, + ContainerName: c.Name, + LXCName: c.LxcName(), + Status: c.Status, + Address: c.IPv6, + PrefixLen: c.IPv6PrefixLen, + Interface: c.IPv6Interface, + }) + } + } + sort.SliceStable(nat4Mappings, func(i, j int) bool { + if nat4Mappings[i].HostPort == nat4Mappings[j].HostPort { + return nat4Mappings[i].ContainerName < nat4Mappings[j].ContainerName + } + return nat4Mappings[i].HostPort < nat4Mappings[j].HostPort + }) + sort.SliceStable(ipv6Assignments, func(i, j int) bool { + return ipv6Assignments[i].Address < ipv6Assignments[j].Address + }) + + const totalNAT4Ports = nat4EndPort - nat4StartPort + 1 + nat4Used := len(usedPorts) + nat4Remaining := totalNAT4Ports - nat4Used + if nat4Remaining < 0 { + nat4Remaining = 0 + } + + prefixes := lxc.DetectPublicIPv6Prefixes() + ipv6Total := "0" + ipv6Remaining := "0" + if len(prefixes) > 0 { + ipv6Total = lxc.IPv6PrefixCapacity(prefixes[0].PrefixLen) + ipv6Remaining = subtractCapacity(ipv6Total, len(ipv6Assignments)) + } + + jsonResponse(w, http.StatusOK, APIResponse{ + Success: true, + Data: routingResponse{ + NAT4: routeCapacity{ + Used: nat4Used, + Remaining: strconv.Itoa(nat4Remaining), + Total: strconv.Itoa(totalNAT4Ports), + }, + IPv6: routeCapacity{ + Used: len(ipv6Assignments), + Remaining: ipv6Remaining, + Total: ipv6Total, + }, + NAT4Mappings: nat4Mappings, + IPv6Assignments: ipv6Assignments, + IPv6Prefixes: prefixes, + }, + }) +} + +func subtractCapacity(total string, used int) string { + if total == "" || total == "0" { + return "0" + } + if total == "large" { + return "large" + } + parsed, err := strconv.ParseInt(total, 10, 64) + if err != nil { + return total + } + remaining := parsed - int64(used) + if remaining < 0 { + remaining = 0 + } + return strconv.FormatInt(remaining, 10) +} diff --git a/backend/internal/lxc/ipv6.go b/backend/internal/lxc/ipv6.go index 4495ed3..a617b10 100644 --- a/backend/internal/lxc/ipv6.go +++ b/backend/internal/lxc/ipv6.go @@ -728,6 +728,27 @@ func ensureIPv6ForwardRules(ipv6 string) { } } +func removeHostIPv6Routing(ipv6, uplink string) { + removeIPv6NAT66(ipv6, uplink) + removeIPv6ForwardRules(ipv6) + runQuiet("ip", "-6", "route", "del", ipv6+"/128", "dev", "lxcbr0") + if uplink != "" { + runQuiet("ip", "-6", "neigh", "del", "proxy", ipv6, "dev", uplink) + } +} + +func removeIPv6ForwardRules(ipv6 string) { + rules := [][]string{ + {"FORWARD", "-i", "lxcbr0", "-s", ipv6 + "/128", "-j", "ACCEPT"}, + {"FORWARD", "-o", "lxcbr0", "-d", ipv6 + "/128", "-j", "ACCEPT"}, + } + for _, rule := range rules { + del := append([]string{"-D"}, rule...) + for exec.Command("ip6tables", del...).Run() == nil { + } + } +} + func containerIPv6ConnectivityOK(lxcName string) bool { targets := []string{"2606:4700:4700::1111", "2001:4860:4860::8888"} for _, target := range targets { diff --git a/backend/internal/lxc/lxc.go b/backend/internal/lxc/lxc.go index 712e3cb..0301cb2 100644 --- a/backend/internal/lxc/lxc.go +++ b/backend/internal/lxc/lxc.go @@ -1425,7 +1425,7 @@ func (m *Manager) DestroyContainer(id int) error { } lxcName := c.LxcName() if c.IPv6 != "" && c.IPv6Interface != "" { - removeIPv6NAT66(c.IPv6, c.IPv6Interface) + removeHostIPv6Routing(c.IPv6, c.IPv6Interface) } if err := m.StopContainer(id); err != nil { diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go index 060b9a5..c061335 100644 --- a/backend/internal/server/server.go +++ b/backend/internal/server/server.go @@ -88,6 +88,7 @@ func setupRoutes(mux *http.ServeMux) { mux.HandleFunc("/api/dashboard", corsMiddleware(api.AdminMiddleware(api.HandleDashboard))) mux.HandleFunc("/api/host-info", corsMiddleware(api.AdminMiddleware(api.HandleHostInfo))) mux.HandleFunc("/api/snapshots", corsMiddleware(api.AdminMiddleware(api.HandleSnapshots))) + mux.HandleFunc("/api/routing", corsMiddleware(api.AdminMiddleware(api.HandleRouting))) mux.HandleFunc("/api/ipv6/status", corsMiddleware(api.AdminMiddleware(api.HandleIPv6Status))) mux.HandleFunc("/api/oversell", corsMiddleware(api.AdminMiddleware(api.HandleOversell))) mux.HandleFunc("/api/oversell/status", corsMiddleware(api.AdminMiddleware(api.HandleOversellStatus))) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6163185..5aa9904 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -11,6 +11,7 @@ import ApiIntegration from './pages/ApiIntegration' import Settings from './pages/Settings' import ImageManagement from './pages/ImageManagement' import Snapshots from './pages/Snapshots' +import Routing from './pages/Routing' import Layout from './components/Layout' function ProtectedRoute({ children }: { children: React.ReactNode }) { @@ -59,6 +60,7 @@ function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index a3efd34..d56adbb 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -7,6 +7,7 @@ import { LayoutDashboard, LogOut, Package, + Route, ScrollText, Server, Settings2, @@ -33,6 +34,7 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) { const isImagesPage = location.pathname.startsWith('/images') const isOversellPage = location.pathname.startsWith('/oversell') const isSnapshotsPage = location.pathname.startsWith('/snapshots') + const isRoutingPage = location.pathname.startsWith('/routing') const isAuditLogsPage = location.pathname.startsWith('/audit-logs') const isApiIntegrationPage = location.pathname.startsWith('/api-integration') const isSecurityPage = location.pathname.startsWith('/security') @@ -150,6 +152,18 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) { {!collapsed && 快照管理} + + + + +
+ } + remaining={routing?.nat4.remaining || '0'} + total={routing?.nat4.total || '0'} + used={routing?.nat4.used || 0} + label="剩余端口 / 端口总数" + /> + } + remaining={formatCapacity(routing?.ipv6.remaining || '0')} + total={formatCapacity(routing?.ipv6.total || '0')} + used={routing?.ipv6.used || 0} + label={`剩余地址 / 地址总数 · ${ipv6Prefix}`} + /> +
+ +
+
+
NAT4 端口分配
+
共 {nat4Mappings.length} 条映射
+
+ {nat4Mappings.length === 0 ? ( + } text="暂无 NAT4 端口映射" /> + ) : ( + <> +
+ + + + + + + + + + + + + + + {pagedNat4Mappings.map((mapping, index) => ( + + + + + + + + + + + ))} + +
容器LXC 名称容器 IPv4宿主机端口容器端口协议说明状态
+ + {mapping.lxc_name}{mapping.ip || '-'}{mapping.host_port}{mapping.container_port}{mapping.protocol || '-'}{mapping.description || '-'}
+
+ + + )} +
+ +
+
+
IPv6 地址分配
+
共 {ipv6Assignments.length} 个地址
+
+ {ipv6Assignments.length === 0 ? ( + } text="暂无 IPv6 地址分配" /> + ) : ( + <> +
+ + + + + + + + + + + + + {pagedIPv6Assignments.map((item) => ( + + + + + + + + + ))} + +
容器LXC 名称IPv6 地址前缀出口网卡状态
+ + {item.lxc_name}{item.address}/{item.prefix_len || '-'}{item.interface || '-'}
+
+ + + )} +
+ + ) +} + +function Pagination({ page, totalPages, totalItems, pageSize, onPageChange }: { + page: number + totalPages: number + totalItems: number + pageSize: number + onPageChange: (page: number) => void +}) { + if (totalPages <= 1) return null + + const start = (page - 1) * pageSize + 1 + const end = Math.min(page * pageSize, totalItems) + + return ( +
+
+ 显示 {start}-{end},共 {totalItems} 条 +
+
+ + + {page} / {totalPages} + + +
+
+ ) +} + +function CapacityCard({ title, icon, remaining, total, used, label }: { + title: string + icon: React.ReactNode + remaining: string + total: string + used: number + label: string +}) { + return ( +
+
+
+
{title}
+
+ {remaining} + / {total} +
+
+
+ {icon} +
+
+
{label}
+
已分配 {used}
+
+ ) +} + +function EmptyState({ icon, text }: { icon: React.ReactNode; text: string }) { + return ( +
+
+ {icon} +
+
{text}
+
+ ) +} + +function StatusBadge({ status }: { status: string }) { + const running = status === 'running' + return ( + + {running ? '运行中' : (status || '未知')} + + ) +} + +function formatCapacity(value: string): string { + if (value === 'large') return '充足' + return value +} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 26c4989..0e962d0 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -283,6 +283,45 @@ export const getIPv6Status = () => export const assignIPv6 = (id: ContainerIdentifier) => api.post>(`/containers/${id}/ipv6`) +export interface RouteCapacity { + used: number + remaining: string + total: string +} + +export interface NAT4Route { + container_id: number + container_name: string + lxc_name: string + status: string + ip: string + host_port: number + container_port: number + protocol: string + description: string +} + +export interface IPv6Route { + container_id: number + container_name: string + lxc_name: string + status: string + address: string + prefix_len: number + interface: string +} + +export interface RoutingInfo { + nat4: RouteCapacity + ipv6: RouteCapacity + nat4_mappings: NAT4Route[] + ipv6_assignments: IPv6Route[] + ipv6_prefixes: IPv6PrefixInfo[] +} + +export const getRoutingInfo = () => + api.get>('/routing') + // Templates export const getTemplates = () => api.get>('/templates')