Initial commit
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
// Package api provides HTTP API handlers using Gin framework.
|
||||
package api
|
||||
@@ -0,0 +1,131 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"git.zkcoi.com/zkcoi/meshray/internal/model"
|
||||
)
|
||||
|
||||
// NetworkResponse 网络响应 DTO(ID 为字符串格式,避免 JavaScript 精度丢失)
|
||||
type NetworkResponse struct {
|
||||
ID string `json:"id"` // ❄️ 雪花算法 ID(字符串格式)
|
||||
Name string `json:"name"` // 组网名称
|
||||
SubnetIPv4 string `json:"subnet_ipv4"` // IPv4 子网(snake_case)
|
||||
MeshMode string `json:"mesh_mode"` // 组网模式(enhanced/native)
|
||||
WGMode string `json:"wg_mode"` // WG 运行模式(kernel/userspace)
|
||||
PolicyID string `json:"policy_id"` // ❄️ 关联策略 ID(字符串格式)
|
||||
Status string `json:"status"` // 运行状态(running/stopped)
|
||||
DeviceCount int64 `json:"device_count"` // 设备数量(统计字段)
|
||||
Description string `json:"description,omitempty"` // 描述
|
||||
CreatedAt string `json:"created_at"` // 创建时间
|
||||
UpdatedAt string `json:"updated_at"` // 更新时间
|
||||
}
|
||||
|
||||
// DeviceResponse 设备响应 DTO(ID 为字符串格式,避免 JavaScript 精度丢失)
|
||||
type DeviceResponse struct {
|
||||
ID string `json:"id"` // ❄️ 雪花算法 ID(字符串格式)
|
||||
NetworkID string `json:"networkId"` // ❄️ 所属网络 ID(字符串格式)
|
||||
Name string `json:"name"` // 设备名称
|
||||
VirtualIP string `json:"virtualIP"` // 虚拟 IP
|
||||
PublicKey string `json:"publicKey"` // WireGuard 公钥
|
||||
Endpoint string `json:"endpoint,omitempty"` // 公网 Endpoint
|
||||
IsRelayCapable bool `json:"isRelayCapable"` // 是否中继节点
|
||||
Status string `json:"status"` // 在线状态(online/offline)
|
||||
Description string `json:"description,omitempty"` // 描述
|
||||
CreatedAt string `json:"createdAt"` // 创建时间
|
||||
UpdatedAt string `json:"updatedAt"` // 更新时间
|
||||
}
|
||||
|
||||
// PolicyResponse 策略响应 DTO(ID 为字符串格式)
|
||||
type PolicyResponse struct {
|
||||
ID string `json:"id"` // ❄️ 雪花算法 ID(字符串格式)
|
||||
Name string `json:"name"` // 策略名称
|
||||
Type string `json:"type"` // 类型(system/custom)
|
||||
LayerConfig string `json:"layerConfig"` // 链路配置 JSON
|
||||
GlobalParams string `json:"globalParams"` // 全局参数 JSON
|
||||
Enabled bool `json:"enabled"` // 是否启用
|
||||
IsDefault bool `json:"isDefault"` // 是否默认策略
|
||||
Description string `json:"description,omitempty"` // 描述
|
||||
CreatedAt string `json:"createdAt"` // 创建时间
|
||||
UpdatedAt string `json:"updatedAt"` // 更新时间
|
||||
}
|
||||
|
||||
// ToNetworkResponse Network 转 NetworkResponse
|
||||
func ToNetworkResponse(network *model.Network) NetworkResponse {
|
||||
// 计算设备数量
|
||||
var deviceCount int64 = 0
|
||||
if len(network.Devices) > 0 {
|
||||
deviceCount = int64(len(network.Devices))
|
||||
}
|
||||
|
||||
return NetworkResponse{
|
||||
ID: fmt.Sprintf("%d", network.ID),
|
||||
Name: network.Name,
|
||||
SubnetIPv4: network.SubnetIPv4,
|
||||
MeshMode: network.Mode, // Mode → MeshMode
|
||||
WGMode: network.WGMode,
|
||||
PolicyID: fmt.Sprintf("%d", network.PolicyID),
|
||||
Status: network.Status,
|
||||
DeviceCount: deviceCount, // 新增:设备数量
|
||||
CreatedAt: network.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
UpdatedAt: network.UpdatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
}
|
||||
}
|
||||
|
||||
// ToDeviceResponse Device 转 DeviceResponse
|
||||
func ToDeviceResponse(device *model.Device) DeviceResponse {
|
||||
return DeviceResponse{
|
||||
ID: fmt.Sprintf("%d", device.ID),
|
||||
NetworkID: fmt.Sprintf("%d", device.NetworkID),
|
||||
Name: device.Name,
|
||||
VirtualIP: device.VirtualIP,
|
||||
PublicKey: device.PublicKey,
|
||||
Endpoint: device.Endpoint,
|
||||
IsRelayCapable: device.IsRelayCapable,
|
||||
Status: device.Status,
|
||||
CreatedAt: device.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
}
|
||||
}
|
||||
|
||||
// ToPolicyResponse Policy 转 PolicyResponse
|
||||
func ToPolicyResponse(policy *model.Policy) PolicyResponse {
|
||||
return PolicyResponse{
|
||||
ID: fmt.Sprintf("%d", policy.ID),
|
||||
Name: policy.Name,
|
||||
Type: policy.Type,
|
||||
LayerConfig: policy.LayerConfig,
|
||||
GlobalParams: policy.GlobalParams,
|
||||
Enabled: policy.Enabled,
|
||||
IsDefault: policy.IsDefault,
|
||||
Description: policy.Description,
|
||||
CreatedAt: policy.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
UpdatedAt: policy.UpdatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
}
|
||||
}
|
||||
|
||||
// ToNetworkResponseList 批量转换 Network
|
||||
func ToNetworkResponseList(networks []model.Network) []NetworkResponse {
|
||||
result := make([]NetworkResponse, 0, len(networks))
|
||||
for _, n := range networks {
|
||||
result = append(result, ToNetworkResponse(&n))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ToDeviceResponseList 批量转换 Device
|
||||
func ToDeviceResponseList(devices []model.Device) []DeviceResponse {
|
||||
result := make([]DeviceResponse, 0, len(devices))
|
||||
for _, d := range devices {
|
||||
result = append(result, ToDeviceResponse(&d))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ToPolicyResponseList 批量转换 Policy
|
||||
func ToPolicyResponseList(policies []model.Policy) []PolicyResponse {
|
||||
result := make([]PolicyResponse, 0, len(policies))
|
||||
for _, p := range policies {
|
||||
result = append(result, ToPolicyResponse(&p))
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.zkcoi.com/zkcoi/meshray/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// AdminHandler 管理员 Handler
|
||||
type AdminHandler struct {
|
||||
userService *service.UserService
|
||||
restartCoreSvc *service.RestartCoreService
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewAdminHandler 创建管理员 Handler
|
||||
func NewAdminHandler(userService *service.UserService, logger *zap.Logger) *AdminHandler {
|
||||
return &AdminHandler{
|
||||
userService: userService,
|
||||
restartCoreSvc: service.NewRestartCoreService(logger),
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// GetProfile 获取管理员信息
|
||||
func (h *AdminHandler) GetProfile(c *gin.Context) {
|
||||
// 从上下文获取用户 ID(由 JWT 中间件注入)
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "未认证"})
|
||||
return
|
||||
}
|
||||
|
||||
// 获取用户信息
|
||||
user, err := h.userService.GetUserByID(userID.(uint))
|
||||
if err != nil {
|
||||
h.logger.Error("获取管理员信息失败", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取信息失败"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": gin.H{
|
||||
"id": user.ID,
|
||||
"username": user.Username,
|
||||
"email": user.Email,
|
||||
"role": user.Role,
|
||||
"status": user.Status,
|
||||
"created_at": user.CreatedAt,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateProfileRequest 更新资料请求
|
||||
type UpdateProfileRequest struct {
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"` // 可选,留空则不修改
|
||||
}
|
||||
|
||||
// UpdateProfile 更新管理员资料
|
||||
func (h *AdminHandler) UpdateProfile(c *gin.Context) {
|
||||
var req UpdateProfileRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数错误"})
|
||||
return
|
||||
}
|
||||
|
||||
// 从上下文获取当前用户名(用于验证)
|
||||
currentUsername, _ := c.Get("username")
|
||||
|
||||
// 如果修改了用户名,需要验证不为空
|
||||
if req.Username != "" && req.Username != currentUsername {
|
||||
// 用户名不能为空
|
||||
if len(req.Username) < 3 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "用户名至少 3 个字符"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 如果提供了密码,验证长度
|
||||
if req.Password != "" && len(req.Password) < 6 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "密码至少 6 个字符"})
|
||||
return
|
||||
}
|
||||
|
||||
// 如果提供了邮箱,验证格式
|
||||
if req.Email != "" {
|
||||
if len(req.Email) < 5 || len(req.Email) > 255 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "邮箱格式无效"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 调用服务层更新
|
||||
err := h.userService.UpdateAdminProfile(req.Username, req.Email, req.Password)
|
||||
if err != nil {
|
||||
h.logger.Error("更新管理员资料失败", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("管理员资料已更新",
|
||||
zap.String("username", req.Username),
|
||||
zap.Bool("password_changed", req.Password != ""))
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "资料已更新",
|
||||
"data": gin.H{
|
||||
"username": req.Username,
|
||||
"email": req.Email,
|
||||
"password_changed": req.Password != "",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ChangePassword 修改密码
|
||||
func (h *AdminHandler) ChangePassword(c *gin.Context) {
|
||||
var req ChangePasswordRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数错误"})
|
||||
return
|
||||
}
|
||||
|
||||
// 从上下文获取用户 ID
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "未认证"})
|
||||
return
|
||||
}
|
||||
|
||||
// 调用服务层修改密码
|
||||
err := h.userService.ChangePassword(userID.(uint), &service.ChangePasswordRequest{
|
||||
OldPassword: req.OldPassword,
|
||||
NewPassword: req.NewPassword,
|
||||
})
|
||||
if err != nil {
|
||||
h.logger.Error("修改密码失败", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("密码修改成功",
|
||||
zap.Uint("user_id", userID.(uint)))
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "密码已修改",
|
||||
})
|
||||
}
|
||||
|
||||
// AdminRestartCoreRequest 重启核心服务请求(管理员接口)
|
||||
type AdminRestartCoreRequest struct {
|
||||
Force bool `json:"force"` // 是否强制重启
|
||||
}
|
||||
|
||||
// RestartCore 重启核心服务
|
||||
func (h *AdminHandler) RestartCore(c *gin.Context) {
|
||||
var req AdminRestartCoreRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数错误"})
|
||||
return
|
||||
}
|
||||
|
||||
// 从上下文获取用户 ID(验证管理员权限)
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "未认证"})
|
||||
return
|
||||
}
|
||||
|
||||
// 验证是否为管理员
|
||||
user, err := h.userService.GetUserByID(userID.(uint))
|
||||
if err != nil || user.Role != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "需要管理员权限"})
|
||||
return
|
||||
}
|
||||
|
||||
// 调用服务层重启核心
|
||||
err = h.restartCoreSvc.RestartCore()
|
||||
if err != nil {
|
||||
h.logger.Error("重启核心服务失败", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("核心服务已重启",
|
||||
zap.Uint("user_id", userID.(uint)),
|
||||
zap.Bool("force", req.Force))
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "核心服务正在重启",
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"git.zkcoi.com/zkcoi/meshray/internal/model"
|
||||
sqlite "git.zkcoi.com/zkcoi/meshray/internal/store/sqlite"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/shirou/gopsutil/v4/cpu"
|
||||
"github.com/shirou/gopsutil/v4/disk"
|
||||
"github.com/shirou/gopsutil/v4/host"
|
||||
"github.com/shirou/gopsutil/v4/mem"
|
||||
gopsutilNet "github.com/shirou/gopsutil/v4/net"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// DashboardHandler Dashboard Handler
|
||||
type DashboardHandler struct {
|
||||
logger *zap.Logger
|
||||
store *sqlite.Store
|
||||
}
|
||||
|
||||
// NewDashboardHandler 创建 Dashboard Handler
|
||||
func NewDashboardHandler(store *sqlite.Store, logger *zap.Logger) *DashboardHandler {
|
||||
return &DashboardHandler{
|
||||
logger: logger,
|
||||
store: store,
|
||||
}
|
||||
}
|
||||
|
||||
// GetStats 获取统计数据
|
||||
func (h *DashboardHandler) GetStats(c *gin.Context) {
|
||||
var deviceCount, networkCount, onlineCount int64
|
||||
|
||||
// 统计设备数量
|
||||
h.store.DB().Model(&model.Device{}).Count(&deviceCount)
|
||||
|
||||
// 统计网络数量
|
||||
h.store.DB().Model(&model.Network{}).Count(&networkCount)
|
||||
|
||||
// 统计在线设备数量
|
||||
h.store.DB().Model(&model.Device{}).Where("status = ?", "online").Count(&onlineCount)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": gin.H{
|
||||
"total_devices": deviceCount,
|
||||
"total_networks": networkCount,
|
||||
"online_devices": onlineCount,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// GetRecentLogs 获取最近日志
|
||||
func (h *DashboardHandler) GetRecentLogs(c *gin.Context) {
|
||||
limitStr := c.DefaultQuery("limit", "10")
|
||||
limit := 10
|
||||
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 {
|
||||
limit = l
|
||||
}
|
||||
|
||||
// 从数据库查询最近的 AuditLog
|
||||
var logs []model.AuditLog
|
||||
if err := h.store.DB().
|
||||
Order("created_at DESC").
|
||||
Limit(limit).
|
||||
Find(&logs).Error; err != nil {
|
||||
h.logger.Error("查询日志失败", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "查询日志失败",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 转换为前端格式
|
||||
type LogEntry struct {
|
||||
ID uint64 `json:"id"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
Message string `json:"message"`
|
||||
Action string `json:"action"`
|
||||
OperatorIP string `json:"operator_ip"`
|
||||
Type string `json:"type"` // info, success, warning, error
|
||||
}
|
||||
|
||||
logEntries := make([]LogEntry, 0, len(logs))
|
||||
for _, log := range logs {
|
||||
// 从 Detail 中提取信息构建 Message
|
||||
message := log.Action
|
||||
if log.Detail != "" {
|
||||
message = log.Detail
|
||||
}
|
||||
|
||||
logEntries = append(logEntries, LogEntry{
|
||||
ID: uint64(log.ID),
|
||||
Timestamp: log.CreatedAt.Format(time.RFC3339),
|
||||
Message: message,
|
||||
Action: log.Action,
|
||||
OperatorIP: log.OperatorIP,
|
||||
Type: "info", // AuditLog 默认类型
|
||||
})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": logEntries,
|
||||
})
|
||||
}
|
||||
|
||||
// GetSystemInfo 获取系统信息
|
||||
func (h *DashboardHandler) GetSystemInfo(c *gin.Context) {
|
||||
// 获取主机信息
|
||||
hostInfo, err := host.Info()
|
||||
if err != nil {
|
||||
h.logger.Warn("获取主机信息失败", zap.Error(err))
|
||||
hostInfo = &host.InfoStat{} // 使用空对象避免 nil 指针
|
||||
}
|
||||
|
||||
// 获取 CPU 信息
|
||||
cpuPercent, _ := cpu.Percent(0, false)
|
||||
cpuUsage := float64(0)
|
||||
if len(cpuPercent) > 0 {
|
||||
cpuUsage = cpuPercent[0]
|
||||
}
|
||||
|
||||
// 获取内存信息
|
||||
memInfo, _ := mem.VirtualMemory()
|
||||
|
||||
// 获取磁盘信息
|
||||
diskInfo, _ := disk.Usage("/")
|
||||
// Windows 下使用 C:\
|
||||
if runtime.GOOS == "windows" {
|
||||
diskInfo, _ = disk.Usage("C:/")
|
||||
}
|
||||
|
||||
// 获取网络 IO
|
||||
netIO, _ := gopsutilNet.IOCounters(false)
|
||||
|
||||
// 获取本机 IP(IPv4 + IPv6)
|
||||
ipv4, ipv6 := getLocalIPs()
|
||||
ip := ipv4 // 默认返回 IPv4
|
||||
if ipv4 == "unknown" && ipv6 != "unknown" {
|
||||
ip = ipv6 // 如果只有 IPv6,则返回 IPv6
|
||||
}
|
||||
|
||||
// 计算运行时长(小时)
|
||||
uptimeHours := float64(hostInfo.Uptime) / 3600.0
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": gin.H{
|
||||
// 基础信息
|
||||
"hostname": hostInfo.Hostname,
|
||||
"os": hostInfo.OS,
|
||||
"platform": hostInfo.Platform,
|
||||
"os_version": hostInfo.PlatformVersion,
|
||||
"kernel": hostInfo.KernelVersion,
|
||||
"arch": runtime.GOARCH,
|
||||
|
||||
// CPU
|
||||
"cpu_count": runtime.NumCPU(),
|
||||
"cpu_usage": cpuUsage,
|
||||
"cpu_model": getCPUModel(), // 从 cpu.Info() 获取
|
||||
|
||||
// 内存
|
||||
"memory_total": int(memInfo.Total / 1024 / 1024), // MB
|
||||
"memory_used": int(memInfo.Used / 1024 / 1024), // MB
|
||||
"memory_percent": memInfo.UsedPercent,
|
||||
"memory_alloc": int(runtime.MemStats{}.Alloc / 1024 / 1024), // Go 内存
|
||||
|
||||
// 磁盘
|
||||
"disk_total": int(diskInfo.Total / 1024 / 1024 / 1024), // GB
|
||||
"disk_used": int(diskInfo.Used / 1024 / 1024 / 1024), // GB
|
||||
"disk_percent": diskInfo.UsedPercent,
|
||||
|
||||
// 网络
|
||||
"net_sent": netIO[0].BytesSent,
|
||||
"net_recv": netIO[0].BytesRecv,
|
||||
|
||||
// 运行时长
|
||||
"boot_time": formatBootTime(hostInfo),
|
||||
"uptime": uptimeHours,
|
||||
|
||||
// IP 地址(同时返回 IPv4 和 IPv6)
|
||||
"ip": ip, // 保持向后兼容
|
||||
"ipv4": ipv4, // IPv4 地址
|
||||
"ipv6": ipv6, // IPv6 地址
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// getLocalIPs 获取本机 IPv4 和 IPv6 地址
|
||||
func getLocalIPs() (ipv4, ipv6 string) {
|
||||
addrs, err := net.InterfaceAddrs()
|
||||
if err != nil {
|
||||
return "unknown", "unknown"
|
||||
}
|
||||
|
||||
for _, addr := range addrs {
|
||||
if ipNet, ok := addr.(*net.IPNet); ok && !ipNet.IP.IsLoopback() {
|
||||
// 检查是否为公网地址(可选,目前保留私有地址)
|
||||
if ipNet.IP.To4() != nil {
|
||||
// 优先选择非链路本地地址
|
||||
if ipv4 == "" || !ipNet.IP.IsLinkLocalUnicast() {
|
||||
ipv4 = ipNet.IP.String()
|
||||
}
|
||||
} else if ipNet.IP.To16() != nil {
|
||||
// IPv6
|
||||
if ipv6 == "" || !ipNet.IP.IsLinkLocalUnicast() {
|
||||
ipv6 = ipNet.IP.String()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ipv4 == "" {
|
||||
ipv4 = "unknown"
|
||||
}
|
||||
if ipv6 == "" {
|
||||
ipv6 = "unknown"
|
||||
}
|
||||
return ipv4, ipv6
|
||||
}
|
||||
|
||||
// formatBootTime 格式化启动时间
|
||||
func formatBootTime(hostInfo *host.InfoStat) string {
|
||||
if hostInfo == nil || hostInfo.BootTime == 0 {
|
||||
return "unknown"
|
||||
}
|
||||
return time.Unix(int64(hostInfo.BootTime), 0).Format("2006-01-02 15:04:05")
|
||||
}
|
||||
|
||||
// GetLinkDistribution 获取链路分布
|
||||
func (h *DashboardHandler) GetLinkDistribution(c *gin.Context) {
|
||||
// 查询所有网络
|
||||
var networks []model.Network
|
||||
if err := h.store.DB().Find(&networks).Error; err != nil {
|
||||
h.logger.Error("查询网络失败", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "查询网络失败",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 构建链路分布数据
|
||||
distribution := make([]gin.H, 0, len(networks))
|
||||
totalP2P := 0
|
||||
totalRelay := 0
|
||||
|
||||
for _, network := range networks {
|
||||
// 查询该网络下的所有设备
|
||||
var devices []model.Device
|
||||
if err := h.store.DB().Where("network_id = ?", network.ID).Find(&devices).Error; err != nil {
|
||||
h.logger.Warn("查询设备失败", zap.Uint64("network_id", network.ID), zap.Error(err))
|
||||
continue
|
||||
}
|
||||
|
||||
// 统计 P2P 和 Relay 连接
|
||||
p2pCount := 0
|
||||
relayCount := 0
|
||||
|
||||
for _, device := range devices {
|
||||
// 根据 Endpoint 判断是 P2P 还是 Relay
|
||||
// 如果 Endpoint 为空或为内网地址,认为是 P2P
|
||||
// 如果 Endpoint 包含中继端口(如 53493),认为是 Relay
|
||||
if device.Endpoint == "" {
|
||||
p2pCount++
|
||||
} else if isRelayEndpoint(device.Endpoint) {
|
||||
relayCount++
|
||||
} else {
|
||||
p2pCount++
|
||||
}
|
||||
}
|
||||
|
||||
totalP2P += p2pCount
|
||||
totalRelay += relayCount
|
||||
|
||||
distribution = append(distribution, gin.H{
|
||||
"network_id": network.ID,
|
||||
"network_name": network.Name,
|
||||
"p2p_count": p2pCount,
|
||||
"relay_count": relayCount,
|
||||
"total_peers": len(devices),
|
||||
"mode": network.Mode, // native / userspace
|
||||
})
|
||||
}
|
||||
|
||||
// 返回总体统计和详细分布
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": gin.H{
|
||||
"summary": gin.H{
|
||||
"total_p2p": totalP2P,
|
||||
"total_relay": totalRelay,
|
||||
"total": totalP2P + totalRelay,
|
||||
"p2p_percent": calculatePercent(totalP2P, totalP2P+totalRelay),
|
||||
},
|
||||
"by_network": distribution,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// isRelayEndpoint 判断是否为中继端点
|
||||
func isRelayEndpoint(endpoint string) bool {
|
||||
// 简单的启发式判断:如果端口在常见 TURN 端口范围内
|
||||
_, port, err := net.SplitHostPort(endpoint)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// 常见 TURN/Relay 端口
|
||||
relayPorts := []string{"53493", "53494", "53495", "3478", "5349"}
|
||||
for _, p := range relayPorts {
|
||||
if port == p {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// calculatePercent 计算百分比
|
||||
func calculatePercent(part, total int) float64 {
|
||||
if total == 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(part) / float64(total) * 100
|
||||
}
|
||||
|
||||
// ClearLogs 清理日志文件
|
||||
func (h *DashboardHandler) ClearLogs(c *gin.Context) {
|
||||
logPath := "./logs"
|
||||
|
||||
// 检查日志目录是否存在
|
||||
if _, err := os.Stat(logPath); os.IsNotExist(err) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "日志目录不存在",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 读取所有日志文件
|
||||
files, err := filepath.Glob(filepath.Join(logPath, "*.log"))
|
||||
if err != nil {
|
||||
h.logger.Error("读取日志文件失败", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "读取日志文件失败",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 删除所有日志文件
|
||||
deletedCount := 0
|
||||
for _, file := range files {
|
||||
if err := os.Remove(file); err != nil {
|
||||
h.logger.Warn("删除日志文件失败", zap.String("file", file), zap.Error(err))
|
||||
continue
|
||||
}
|
||||
deletedCount++
|
||||
}
|
||||
|
||||
h.logger.Info("清理日志完成", zap.Int("deleted", deletedCount))
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "日志清理完成",
|
||||
"deleted": deletedCount,
|
||||
})
|
||||
}
|
||||
|
||||
// getCPUModel 获取 CPU 型号
|
||||
func getCPUModel() string {
|
||||
cpuInfos, err := cpu.Info()
|
||||
if err != nil || len(cpuInfos) == 0 {
|
||||
return "Unknown"
|
||||
}
|
||||
return cpuInfos[0].ModelName
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.zkcoi.com/zkcoi/meshray/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// DDNSHandler DDNS 配置处理器
|
||||
type DDNSHandler struct {
|
||||
ddnsService *service.DDNSService
|
||||
}
|
||||
|
||||
// NewDDNSHandler 创建 DDNS 处理器
|
||||
func NewDDNSHandler(ddnsService *service.DDNSService) *DDNSHandler {
|
||||
return &DDNSHandler{
|
||||
ddnsService: ddnsService,
|
||||
}
|
||||
}
|
||||
|
||||
// DDNSConfigRequest DDNS 配置请求
|
||||
type DDNSConfigRequest struct {
|
||||
Provider string `json:"provider"`
|
||||
AccessKeyID string `json:"access_key_id"`
|
||||
AccessKeySecret string `json:"access_key_secret"`
|
||||
Domain string `json:"domain"`
|
||||
TxtRecordName string `json:"txt_record_name"`
|
||||
SyncMode string `json:"sync_mode"`
|
||||
RetryInterval int `json:"retry_interval"`
|
||||
MaxRetries int `json:"max_retries"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
// GetDDNSConfig 获取 DDNS 配置
|
||||
// GET /api/v1/ddns/config
|
||||
func (h *DDNSHandler) GetDDNSConfig(c *gin.Context) {
|
||||
config, err := h.ddnsService.GetConfig(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": config,
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateDDNSConfig 更新 DDNS 配置
|
||||
// PUT /api/v1/ddns/config
|
||||
func (h *DDNSHandler) UpdateDDNSConfig(c *gin.Context) {
|
||||
var req DDNSConfigRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "参数错误:" + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if req.Provider == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "请选择 DNS 提供商",
|
||||
})
|
||||
return
|
||||
}
|
||||
if req.AccessKeyID == "" || req.AccessKeySecret == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "请输入完整的访问密钥",
|
||||
})
|
||||
return
|
||||
}
|
||||
if req.Domain == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "请输入域名",
|
||||
})
|
||||
return
|
||||
}
|
||||
if req.TxtRecordName == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "请输入 TXT 记录名称",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.ddnsService.UpdateConfig(c.Request.Context(), req); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "配置已保存",
|
||||
})
|
||||
}
|
||||
|
||||
// TestDDNSRequest 测试请求
|
||||
type TestDDNSRequest struct {
|
||||
Provider string `json:"provider"`
|
||||
AccessKeyID string `json:"access_key_id"`
|
||||
AccessKeySecret string `json:"access_key_secret"`
|
||||
Domain string `json:"domain"`
|
||||
TxtRecordName string `json:"txt_record_name"`
|
||||
}
|
||||
|
||||
// TestDDNSConnectivity 测试 DDNS 连通性
|
||||
// POST /api/v1/ddns/test
|
||||
func (h *DDNSHandler) TestDDNSConnectivity(c *gin.Context) {
|
||||
var req TestDDNSRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "参数错误:" + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
results := h.ddnsService.TestConnectivity(c.Request.Context(), req)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": gin.H{
|
||||
"results": results,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// SyncDDNS 手动同步 DDNS
|
||||
// POST /api/v1/ddns/sync
|
||||
func (h *DDNSHandler) SyncDDNS(c *gin.Context) {
|
||||
if err := h.ddnsService.SyncNow(c.Request.Context()); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "同步成功",
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.zkcoi.com/zkcoi/meshray/internal/model"
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// DDNSStatsHandler DDNS 统计处理器
|
||||
type DDNSStatsHandler struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewDDNSStatsHandler 创建 DDNS 统计处理器
|
||||
func NewDDNSStatsHandler(db *gorm.DB) *DDNSStatsHandler {
|
||||
return &DDNSStatsHandler{
|
||||
db: db,
|
||||
}
|
||||
}
|
||||
|
||||
// DDNSServiceInfo DDNS 服务信息(返回给前端)
|
||||
type DDNSServiceInfo struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
FullDomain string `json:"full_domain"` // 完整域名:subdomain.domain
|
||||
CurrentIP string `json:"current_ip"` // 当前 IP
|
||||
RecordType string `json:"record_type"` // A/AAAA/TXT/CNAME
|
||||
Enabled bool `json:"enabled"` // 是否启用
|
||||
Status string `json:"status"` // active/disabled
|
||||
LastUpdated string `json:"last_updated"` // 最后更新时间
|
||||
}
|
||||
|
||||
// GetDDNSStats 获取 DDNS 统计数据
|
||||
func (h *DDNSStatsHandler) GetDDNSStats(c *gin.Context) {
|
||||
// 查询所有 DDNS 全功能模式服务
|
||||
var services []model.Service
|
||||
if err := h.db.Where("type = ? AND config_mode = ?", "DDNS", "fullservice").Find(&services).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"code": 500,
|
||||
"message": "查询 DDNS 服务失败:" + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 统计数据
|
||||
total := len(services)
|
||||
active := 0
|
||||
|
||||
// 构建服务列表
|
||||
serviceList := make([]DDNSServiceInfo, 0, len(services))
|
||||
for _, svc := range services {
|
||||
status := "disabled"
|
||||
if svc.Enabled {
|
||||
status = "active"
|
||||
active++
|
||||
}
|
||||
|
||||
fullDomain := ""
|
||||
rootDomain := h.getDDNSDomain(svc.DDNSConfigID)
|
||||
switch svc.RecordType {
|
||||
case "A", "AAAA":
|
||||
if svc.Subdomain != "" && rootDomain != "" {
|
||||
fullDomain = svc.Subdomain + "." + rootDomain
|
||||
}
|
||||
case "TXT":
|
||||
if svc.TXTRecordName != "" && rootDomain != "" {
|
||||
fullDomain = svc.TXTRecordName + "." + rootDomain
|
||||
}
|
||||
case "CNAME":
|
||||
if svc.Subdomain != "" && rootDomain != "" {
|
||||
fullDomain = svc.Subdomain + "." + rootDomain
|
||||
}
|
||||
}
|
||||
|
||||
currentIP := svc.TargetIP
|
||||
if svc.RecordType == "TXT" {
|
||||
currentIP = "-"
|
||||
}
|
||||
|
||||
lastUpdated := ""
|
||||
if !svc.UpdatedAt.IsZero() {
|
||||
lastUpdated = svc.UpdatedAt.Format(time.RFC3339)
|
||||
}
|
||||
|
||||
serviceList = append(serviceList, DDNSServiceInfo{
|
||||
ID: svc.ID,
|
||||
Name: svc.Name,
|
||||
FullDomain: fullDomain,
|
||||
CurrentIP: currentIP,
|
||||
RecordType: svc.RecordType,
|
||||
Enabled: svc.Enabled,
|
||||
Status: status,
|
||||
LastUpdated: lastUpdated,
|
||||
})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 0,
|
||||
"data": gin.H{
|
||||
"total": total,
|
||||
"active": active,
|
||||
"services": serviceList,
|
||||
},
|
||||
"message": "获取成功",
|
||||
})
|
||||
}
|
||||
|
||||
// getDDNSDomain 获取 DDNS 配置中的根域名
|
||||
func (h *DDNSStatsHandler) getDDNSDomain(ddnsConfigID string) string {
|
||||
if ddnsConfigID == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
var extService model.ExternalService
|
||||
if err := h.db.Where("id = ?", ddnsConfigID).First(&extService).Error; err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
var config map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(extService.Config), &config); err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
if rootDomain, ok := config["root_domain"].(string); ok {
|
||||
return rootDomain
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"git.zkcoi.com/zkcoi/meshray/internal/model"
|
||||
"git.zkcoi.com/zkcoi/meshray/pkg/shortid"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// DDNSUsageHandler DDNS Usage 管理 Handler
|
||||
type DDNSUsageHandler struct {
|
||||
db *gorm.DB
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewDDNSUsageHandler 创建 DDNS Usage Handler
|
||||
func NewDDNSUsageHandler(db *gorm.DB, logger *zap.Logger) *DDNSUsageHandler {
|
||||
return &DDNSUsageHandler{
|
||||
db: db,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// CreateUsageRequest 创建 Usage 请求
|
||||
type CreateUsageRequest struct {
|
||||
ServiceID string `json:"service_id"` // DDNS 服务 ID
|
||||
PrefixMode string `json:"prefix_mode"` // "auto" | "custom"
|
||||
CustomPrefix string `json:"custom_prefix"` // 自定义前缀(仅 custom 模式需要)
|
||||
NetworkID uint64 `json:"network_id"` // 绑定的网络 ID
|
||||
NetworkName string `json:"network_name"` // 网络名称(用于显示)
|
||||
}
|
||||
|
||||
// CreateUsage 创建 DDNS Usage
|
||||
// POST /api/v1/ddns/usages
|
||||
func (h *DDNSUsageHandler) CreateUsage(c *gin.Context) {
|
||||
var req CreateUsageRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数错误"})
|
||||
return
|
||||
}
|
||||
|
||||
// 验证必填字段
|
||||
if req.ServiceID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请选择 DDNS 服务"})
|
||||
return
|
||||
}
|
||||
|
||||
if req.PrefixMode != "auto" && req.PrefixMode != "custom" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的前缀模式"})
|
||||
return
|
||||
}
|
||||
|
||||
// 开启事务
|
||||
tx := h.db.Begin()
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
tx.Rollback()
|
||||
}
|
||||
}()
|
||||
|
||||
// 1. 验证 DDNS 服务存在
|
||||
var service model.ExternalService
|
||||
if err := tx.First(&service, req.ServiceID).Error; err != nil {
|
||||
tx.Rollback()
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "DDNS 服务不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 解析配置获取域名
|
||||
var config map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(service.Config), &config); err != nil {
|
||||
tx.Rollback()
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "解析 DDNS 配置失败"})
|
||||
return
|
||||
}
|
||||
domain, _ := config["domain"].(string)
|
||||
if domain == "" {
|
||||
tx.Rollback()
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "DDNS 配置缺少域名"})
|
||||
return
|
||||
}
|
||||
|
||||
// 3. 根据模式生成前缀
|
||||
var recordPrefix string
|
||||
if req.PrefixMode == "auto" {
|
||||
// ✅ 算法生成:Base64(NetworkID)
|
||||
recordPrefix = shortid.EncodeID(req.NetworkID)
|
||||
h.logger.Debug("自动生成前缀",
|
||||
zap.Uint64("network_id", req.NetworkID),
|
||||
zap.String("prefix", recordPrefix))
|
||||
|
||||
} else if req.PrefixMode == "custom" {
|
||||
// ✅ 用户自定义:验证格式 + 检测占用
|
||||
if err := validateCustomPrefix(req.CustomPrefix); err != nil {
|
||||
tx.Rollback()
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// 检测是否被占用
|
||||
var count int64
|
||||
tx.Model(&model.DDNSUsage{}).
|
||||
Where("service_id = ? AND record_prefix = ?", req.ServiceID, req.CustomPrefix).
|
||||
Count(&count)
|
||||
|
||||
if count > 0 {
|
||||
tx.Rollback()
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "该前缀已被其他组网占用"})
|
||||
return
|
||||
}
|
||||
|
||||
recordPrefix = req.CustomPrefix
|
||||
h.logger.Debug("使用自定义前缀",
|
||||
zap.String("prefix", req.CustomPrefix))
|
||||
}
|
||||
|
||||
// 4. 创建 Usage 记录
|
||||
usage := &model.DDNSUsage{
|
||||
ID: uuid.New().String(),
|
||||
ProviderID: req.ServiceID, // ✅ 使用 ProviderID 字段
|
||||
UsageType: "meshseed_sync", // MeshSeed 同步用途
|
||||
PrefixMode: req.PrefixMode,
|
||||
RecordPrefix: recordPrefix,
|
||||
RecordType: "TXT", // MeshSeed 同步固定使用 TXT 记录
|
||||
Description: fmt.Sprintf("MeshSeed 同步 - %s", req.NetworkName),
|
||||
IsExclusive: true, // 独占性,一个前缀只能绑定一个网络
|
||||
}
|
||||
|
||||
if err := tx.Create(usage).Error; err != nil {
|
||||
tx.Rollback()
|
||||
h.logger.Error("创建 Usage 失败", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建失败:" + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// 5. 创建绑定关系
|
||||
binding := &model.NetworkDDNSBinding{
|
||||
ID: uuid.New().String(),
|
||||
NetworkID: req.NetworkID,
|
||||
UsageID: usage.ID,
|
||||
ProviderID: req.ServiceID,
|
||||
Status: "active",
|
||||
}
|
||||
|
||||
if err := tx.Create(binding).Error; err != nil {
|
||||
tx.Rollback()
|
||||
h.logger.Error("创建绑定关系失败", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建绑定失败:" + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// 提交事务
|
||||
if err := tx.Commit().Error; err != nil {
|
||||
h.logger.Error("事务提交失败", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "提交失败"})
|
||||
return
|
||||
}
|
||||
|
||||
// 构造完整域名
|
||||
fullDomain := fmt.Sprintf("_meshray.%s.%s", recordPrefix, domain)
|
||||
|
||||
h.logger.Info("DDNS Usage 创建成功",
|
||||
zap.String("usage_id", usage.ID),
|
||||
zap.Uint64("network_id", req.NetworkID),
|
||||
zap.String("full_domain", fullDomain))
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "创建成功",
|
||||
"data": gin.H{
|
||||
"id": usage.ID,
|
||||
"provider_id": usage.ProviderID,
|
||||
"prefix_mode": usage.PrefixMode,
|
||||
"record_prefix": recordPrefix,
|
||||
"full_domain": fullDomain,
|
||||
"network_id": req.NetworkID,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// CheckPrefixOccupied 检查前缀是否被占用
|
||||
// GET /api/v1/ddns/check-prefix?service_id=xxx&prefix=yyy
|
||||
func (h *DDNSUsageHandler) CheckPrefixOccupied(c *gin.Context) {
|
||||
serviceID := c.Query("service_id")
|
||||
prefix := c.Query("prefix")
|
||||
|
||||
if serviceID == "" || prefix == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "缺少必要参数"})
|
||||
return
|
||||
}
|
||||
|
||||
// 查询占用情况
|
||||
var count int64
|
||||
h.db.Model(&model.DDNSUsage{}).
|
||||
Where("service_id = ? AND record_prefix = ?", serviceID, prefix).
|
||||
Count(&count)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": gin.H{
|
||||
"occupied": count > 0,
|
||||
"count": count,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// GetAvailableUsages 获取可用的 DDNS Usage 列表
|
||||
// GET /api/v1/ddns/usages/available
|
||||
func (h *DDNSUsageHandler) GetAvailableUsages(c *gin.Context) {
|
||||
serviceID := c.Query("service_id")
|
||||
|
||||
// 查询所有 TXT 类型的 Usage
|
||||
query := h.db.Where("record_type = ?", "TXT").
|
||||
Preload("Provider")
|
||||
|
||||
if serviceID != "" {
|
||||
query = query.Where("service_id = ?", serviceID)
|
||||
}
|
||||
|
||||
var usages []model.DDNSUsage
|
||||
if err := query.Find(&usages).Error; err != nil {
|
||||
h.logger.Error("查询 Usage 列表失败", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "查询失败"})
|
||||
return
|
||||
}
|
||||
|
||||
// 构造返回数据
|
||||
type UsageVO struct {
|
||||
ID string `json:"id"`
|
||||
ProviderID string `json:"provider_id"`
|
||||
PrefixMode string `json:"prefix_mode"`
|
||||
RecordPrefix string `json:"record_prefix"`
|
||||
RecordType string `json:"record_type"`
|
||||
Description string `json:"description"`
|
||||
IsOccupied bool `json:"is_occupied"`
|
||||
NetworkID *uint64 `json:"network_id,omitempty"`
|
||||
FullDomain string `json:"full_domain"`
|
||||
Provider interface{} `json:"provider,omitempty"`
|
||||
}
|
||||
|
||||
result := make([]UsageVO, 0, len(usages))
|
||||
for _, usage := range usages {
|
||||
// 获取完整域名
|
||||
var provider model.ExternalService
|
||||
h.db.First(&provider, usage.ProviderID)
|
||||
|
||||
var config map[string]interface{}
|
||||
json.Unmarshal([]byte(provider.Config), &config)
|
||||
domain, _ := config["domain"].(string)
|
||||
|
||||
fullDomain := fmt.Sprintf("_meshray.%s.%s", usage.RecordPrefix, domain)
|
||||
|
||||
// 从 NetworkDDNSBinding 表查询关联的 Network ID
|
||||
var networkID *uint64
|
||||
var binding model.NetworkDDNSBinding
|
||||
if err := h.db.Where("ddns_usage_id = ?", usage.ID).First(&binding).Error; err == nil {
|
||||
networkID = &binding.NetworkID
|
||||
}
|
||||
|
||||
vo := UsageVO{
|
||||
ID: usage.ID,
|
||||
ProviderID: usage.ProviderID,
|
||||
PrefixMode: usage.PrefixMode,
|
||||
RecordPrefix: usage.RecordPrefix,
|
||||
RecordType: usage.RecordType,
|
||||
Description: usage.Description,
|
||||
IsOccupied: usage.IsExclusive,
|
||||
NetworkID: networkID,
|
||||
FullDomain: fullDomain,
|
||||
}
|
||||
|
||||
if usage.Provider != nil {
|
||||
vo.Provider = gin.H{
|
||||
"id": usage.Provider.ID,
|
||||
"name": usage.Provider.Name,
|
||||
}
|
||||
}
|
||||
|
||||
result = append(result, vo)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"data": result})
|
||||
}
|
||||
|
||||
// validateCustomPrefix 验证自定义前缀
|
||||
func validateCustomPrefix(prefix string) error {
|
||||
if prefix == "" {
|
||||
return fmt.Errorf("前缀不能为空")
|
||||
}
|
||||
|
||||
if len(prefix) > 63 {
|
||||
return fmt.Errorf("前缀不能超过 63 个字符")
|
||||
}
|
||||
|
||||
// DNS 标签规范
|
||||
matched, _ := regexp.MatchString(`^[a-zA-Z0-9_-]+$`, prefix)
|
||||
if !matched {
|
||||
return fmt.Errorf("前缀只能包含字母、数字、下划线和连字符")
|
||||
}
|
||||
|
||||
// 系统保留字检查
|
||||
if strings.HasPrefix(prefix, "_meshray") {
|
||||
return fmt.Errorf("_meshray.* 前缀为系统保留,请使用自动生成模式")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"git.zkcoi.com/zkcoi/meshray/internal/api/dto"
|
||||
"git.zkcoi.com/zkcoi/meshray/internal/model"
|
||||
"git.zkcoi.com/zkcoi/meshray/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// DeviceHandler 设备管理 Handler
|
||||
type DeviceHandler struct {
|
||||
deviceService *service.DeviceService
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewDeviceHandler 创建设备处理器
|
||||
func NewDeviceHandler(deviceService *service.DeviceService, logger *zap.Logger) *DeviceHandler {
|
||||
return &DeviceHandler{
|
||||
deviceService: deviceService,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// CreateDevice 创建设备
|
||||
// @Summary 在指定网络下创建设备
|
||||
// @Tags devices
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param network_id path int true "网络 ID"
|
||||
// @Param device body model.Device true "设备配置"
|
||||
// @Success 200 {object} model.Device
|
||||
// @Router /api/v1/networks/:network_id/devices [post]
|
||||
func (h *DeviceHandler) CreateDevice(c *gin.Context) {
|
||||
// 从 Query 参数获取 network_id
|
||||
networkIDStr := c.Query("network_id")
|
||||
networkID, err := strconv.ParseUint(networkIDStr, 10, 64)
|
||||
if err != nil {
|
||||
h.logger.Error("解析网络 ID 失败", zap.Error(err))
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的网络 ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var req service.CreateDeviceRequest
|
||||
req.NetworkID = networkID
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数错误"})
|
||||
return
|
||||
}
|
||||
|
||||
// 使用 Service 层创建设备(返回完整配置)
|
||||
result, err := h.deviceService.CreateDevice(&req)
|
||||
if err != nil {
|
||||
h.logger.Error("创建设备失败", zap.Error(err))
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("设备创建成功",
|
||||
zap.Uint64("id", result.Device.ID),
|
||||
zap.String("name", result.Device.Name),
|
||||
zap.Uint64("network_id", result.Device.NetworkID))
|
||||
|
||||
// 返回完整配置(包含私钥和 WG 配置)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "设备创建成功",
|
||||
"data": gin.H{
|
||||
"device": result.Device,
|
||||
"private_key": result.PrivateKey,
|
||||
"wireguard_config": result.ConfigText,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// GetDevice 获取设备详情
|
||||
// @Summary 获取设备详细信息
|
||||
// @Tags devices
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int true "设备 ID"
|
||||
// @Success 200 {object} model.Device
|
||||
// @Router /api/v1/devices/:id [get]
|
||||
// GetDevice 获取设备详情
|
||||
func (h *DeviceHandler) GetDevice(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 64)
|
||||
if err != nil {
|
||||
h.logger.Error("解析设备 ID 失败", zap.Error(err))
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的设备 ID"})
|
||||
return
|
||||
}
|
||||
|
||||
device, err := h.deviceService.GetDevice(id)
|
||||
if err != nil {
|
||||
h.logger.Error("获取设备失败", zap.Error(err))
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "设备不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
// 使用 DTO 转换
|
||||
resp := dto.ToDeviceResponse(device)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": resp,
|
||||
})
|
||||
}
|
||||
|
||||
// ListDevices 获取设备列表
|
||||
// @Summary 获取指定网络下的设备列表
|
||||
// @Tags devices
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param network_id path int true "网络 ID"
|
||||
// @Success 200 {array} model.Device
|
||||
// @Router /api/v1/networks/:network_id/devices [get]
|
||||
// ListDevices 获取设备列表
|
||||
func (h *DeviceHandler) ListDevices(c *gin.Context) {
|
||||
networkIDStr := c.Query("network_id")
|
||||
|
||||
var devices []model.Device
|
||||
var err error
|
||||
|
||||
if networkIDStr != "" {
|
||||
networkID, err := strconv.ParseUint(networkIDStr, 10, 64)
|
||||
if err != nil {
|
||||
h.logger.Error("解析网络 ID 失败", zap.Error(err))
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的网络 ID"})
|
||||
return
|
||||
}
|
||||
devices, err = h.deviceService.ListDevicesByNetwork(networkID)
|
||||
} else {
|
||||
devices, err = h.deviceService.ListAllDevices()
|
||||
}
|
||||
if err != nil {
|
||||
h.logger.Error("查询设备列表失败", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "查询失败"})
|
||||
return
|
||||
}
|
||||
|
||||
// 使用 DTO 批量转换
|
||||
respList := dto.ToDeviceResponseList(devices)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": respList,
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateDevice 更新设备
|
||||
// @Summary 更新设备信息
|
||||
// @Tags devices
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int true "设备 ID"
|
||||
// @Param device body model.Device true "设备配置"
|
||||
// @Success 200 {object} model.Device
|
||||
// @Router /api/v1/devices/:id [put]
|
||||
func (h *DeviceHandler) UpdateDevice(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 64)
|
||||
if err != nil {
|
||||
h.logger.Error("解析设备 ID 失败", zap.Error(err))
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的设备 ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
VirtualIP string `json:"virtual_ip"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数错误"})
|
||||
return
|
||||
}
|
||||
|
||||
// 构建更新字段
|
||||
updates := make(map[string]interface{})
|
||||
if req.Name != "" {
|
||||
updates["name"] = req.Name
|
||||
}
|
||||
if req.VirtualIP != "" {
|
||||
updates["virtual_ip"] = req.VirtualIP
|
||||
}
|
||||
if req.Status != "" {
|
||||
updates["status"] = req.Status
|
||||
}
|
||||
|
||||
// 使用 Service 层更新
|
||||
device, err := h.deviceService.UpdateDevice(id, updates)
|
||||
if err != nil {
|
||||
h.logger.Error("更新设备失败", zap.Error(err))
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("设备更新成功", zap.Uint64("id", device.ID))
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "更新成功",
|
||||
"data": device,
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteDevice 删除设备
|
||||
// @Summary 删除指定设备
|
||||
// @Tags devices
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int true "设备 ID"
|
||||
// @Success 200
|
||||
// @Router /api/v1/devices/:id [delete]
|
||||
func (h *DeviceHandler) DeleteDevice(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 64)
|
||||
if err != nil {
|
||||
h.logger.Error("解析设备 ID 失败", zap.Error(err))
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的设备 ID"})
|
||||
return
|
||||
}
|
||||
|
||||
// 使用 Service 层删除
|
||||
err = h.deviceService.DeleteDevice(id)
|
||||
if err != nil {
|
||||
h.logger.Error("删除设备失败", zap.Error(err))
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("设备已删除", zap.Uint64("id", id))
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "删除成功",
|
||||
})
|
||||
}
|
||||
|
||||
// GenerateDeviceConfig 生成设备配置文件
|
||||
// @Summary 生成 WireGuard 设备配置
|
||||
// @Tags devices
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int true "设备 ID"
|
||||
// @Success 200 {string} string "配置文件内容"
|
||||
// @Router /api/v1/devices/:id/config [get]
|
||||
func (h *DeviceHandler) GenerateDeviceConfig(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 64)
|
||||
if err != nil {
|
||||
h.logger.Error("解析设备 ID 失败", zap.Error(err))
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的设备 ID"})
|
||||
return
|
||||
}
|
||||
|
||||
// 调用 Service 层生成配置(包含密钥生成)
|
||||
config, err := h.deviceService.GenerateDeviceConfig(id)
|
||||
if err != nil {
|
||||
h.logger.Error("生成配置失败", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.Data(http.StatusOK, "text/plain", []byte(config))
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.zkcoi.com/zkcoi/meshray/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// IPDetectHandler IP 检测处理器
|
||||
type IPDetectHandler struct {
|
||||
ipDetection *service.IPDetectionService
|
||||
}
|
||||
|
||||
// NewIPDetectHandler 创建 IP 检测处理器
|
||||
func NewIPDetectHandler() *IPDetectHandler {
|
||||
return &IPDetectHandler{
|
||||
ipDetection: service.NewIPDetectionService(),
|
||||
}
|
||||
}
|
||||
|
||||
// DetectIP 检测公网 IP 地址
|
||||
// @Summary 检测公网 IP 地址
|
||||
// @Tags DDNS
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param record_type query string false "记录类型 (A|AAAA)" default(A)
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /api/v1/services/ddns/detect-ip [get]
|
||||
func (h *IPDetectHandler) DetectIP(c *gin.Context) {
|
||||
recordType := c.DefaultQuery("record_type", "A")
|
||||
|
||||
if recordType != "A" && recordType != "AAAA" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"code": 400,
|
||||
"message": "不支持的记录类型,仅支持 A 或 AAAA",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
ip, err := h.ipDetection.DetectIP(recordType)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"code": 500,
|
||||
"message": "检测失败:" + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 0,
|
||||
"data": gin.H{
|
||||
"ip": ip,
|
||||
},
|
||||
"message": "检测成功",
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,549 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.zkcoi.com/zkcoi/meshray/internal/api/dto"
|
||||
"git.zkcoi.com/zkcoi/meshray/internal/model"
|
||||
"git.zkcoi.com/zkcoi/meshray/internal/service"
|
||||
sqlite "git.zkcoi.com/zkcoi/meshray/internal/store/sqlite"
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// NetworkHandler 网络管理 Handler
|
||||
type NetworkHandler struct {
|
||||
networkService *service.NetworkService
|
||||
meshSeedService *service.MeshSeedService // 新增:MeshSeed 服务
|
||||
store *sqlite.Store // 新增:用于存储 PendingJoin
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewNetworkHandler 创建 Network Handler
|
||||
func NewNetworkHandler(networkService *service.NetworkService, meshSeedService *service.MeshSeedService, store *sqlite.Store, logger *zap.Logger) *NetworkHandler {
|
||||
return &NetworkHandler{
|
||||
networkService: networkService,
|
||||
meshSeedService: meshSeedService,
|
||||
store: store,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// CreateNetworkResponse 创建网络响应(包含完整配置信息)
|
||||
type CreateNetworkResponse struct {
|
||||
*model.Network
|
||||
STUNServers []model.Service `json:"stun_servers"` // STUN 服务器列表
|
||||
TURNServers []model.Service `json:"turn_servers"` // TURN 服务器列表
|
||||
DDNSConfig *DDNSConfigInfo `json:"ddns_config,omitempty"` // DDNS 配置信息(如果启用)
|
||||
}
|
||||
|
||||
// DDNSConfigInfo DDNS 配置信息
|
||||
type DDNSConfigInfo struct {
|
||||
Provider string `json:"provider"` // 服务商
|
||||
Domain string `json:"domain"` // 域名
|
||||
RecordType string `json:"record_type"` // 记录类型
|
||||
Prefix string `json:"prefix"` // 前缀
|
||||
}
|
||||
|
||||
// CreateNetwork 创建网络
|
||||
// @Summary 创建新的 WireGuard 网络
|
||||
// @Tags networks
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param network body model.Network true "网络配置"
|
||||
// @Success 200 {object} CreateNetworkResponse
|
||||
// @Router /api/v1/networks [post]
|
||||
func (h *NetworkHandler) CreateNetwork(c *gin.Context) {
|
||||
var req model.Network
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数错误"})
|
||||
return
|
||||
}
|
||||
|
||||
// 使用 Service 层创建网络
|
||||
network, err := h.networkService.CreateNetwork(&req)
|
||||
if err != nil {
|
||||
h.logger.Error("创建网络失败", zap.Error(err))
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// 查询关联的 STUN/TURN 服务器
|
||||
var stunServers []model.Service
|
||||
h.store.DB().Where("type = 'STUN' AND enabled = true").Find(&stunServers)
|
||||
|
||||
var turnServers []model.Service
|
||||
h.store.DB().Where("type = 'TURN' AND enabled = true").Find(&turnServers)
|
||||
|
||||
// 构建响应
|
||||
resp := &CreateNetworkResponse{
|
||||
Network: network,
|
||||
STUNServers: stunServers,
|
||||
TURNServers: turnServers,
|
||||
}
|
||||
|
||||
// 如果启用了 DDNS,查询 DDNS 配置
|
||||
if network.DDNSEnabled && network.DDNSServiceID != "" {
|
||||
var ddnsService model.Service
|
||||
if err := h.store.DB().First(&ddnsService, network.DDNSServiceID).Error; err == nil {
|
||||
resp.DDNSConfig = &DDNSConfigInfo{
|
||||
Provider: ddnsService.Provider,
|
||||
Domain: ddnsService.Domain,
|
||||
RecordType: ddnsService.RecordType,
|
||||
Prefix: network.DDNSPrefix,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
h.logger.Info("网络创建成功",
|
||||
zap.String("name", network.Name),
|
||||
zap.Uint64("id", network.ID),
|
||||
zap.Int("stun_count", len(stunServers)),
|
||||
zap.Int("turn_count", len(turnServers)))
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "网络创建成功",
|
||||
"data": resp,
|
||||
})
|
||||
}
|
||||
|
||||
// GetNetwork 获取网络详情
|
||||
// @Summary 获取网络详细信息
|
||||
// @Tags networks
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "网络 ID"
|
||||
// @Success 200 {object} model.Network
|
||||
// @Router /api/v1/networks/:id [get]
|
||||
func (h *NetworkHandler) GetNetwork(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 64)
|
||||
if err != nil {
|
||||
h.logger.Error("解析网络 ID 失败", zap.Error(err))
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的网络 ID"})
|
||||
return
|
||||
}
|
||||
|
||||
// 使用 Service 层获取网络
|
||||
network, err := h.networkService.GetNetwork(id)
|
||||
if err != nil {
|
||||
h.logger.Error("获取网络失败", zap.Error(err))
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "网络不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
// 使用 DTO 转换
|
||||
resp := dto.ToNetworkResponse(network)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": resp,
|
||||
})
|
||||
}
|
||||
|
||||
// ListNetworks 获取网络列表
|
||||
// @Summary 获取所有网络列表
|
||||
// @Tags networks
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {array} model.Network
|
||||
// @Router /api/v1/networks [get]
|
||||
// ListNetworks 获取网络列表
|
||||
func (h *NetworkHandler) ListNetworks(c *gin.Context) {
|
||||
networks, err := h.networkService.ListNetworks()
|
||||
if err != nil {
|
||||
h.logger.Error("查询网络列表失败", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "查询失败"})
|
||||
return
|
||||
}
|
||||
|
||||
// 使用 DTO 批量转换
|
||||
respList := dto.ToNetworkResponseList(networks)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": respList,
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteNetwork 删除网络
|
||||
// @Summary 删除指定网络
|
||||
// @Tags networks
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "网络 ID"
|
||||
// @Success 200
|
||||
// @Router /api/v1/networks/:id [delete]
|
||||
func (h *NetworkHandler) DeleteNetwork(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 64)
|
||||
if err != nil {
|
||||
h.logger.Error("解析网络 ID 失败", zap.Error(err))
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的网络 ID"})
|
||||
return
|
||||
}
|
||||
|
||||
// 获取是否强制级联删除
|
||||
force := c.Query("force") == "true"
|
||||
|
||||
// 使用 Service 层删除网络
|
||||
err = h.networkService.DeleteNetwork(id, force)
|
||||
if err != nil {
|
||||
h.logger.Error("删除网络失败", zap.Error(err))
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("网络已删除", zap.Uint64("id", id))
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "删除成功",
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateNetwork 更新网络
|
||||
// @Summary 更新网络信息
|
||||
// @Tags networks
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "网络 ID"
|
||||
// @Param network body model.Network true "网络配置"
|
||||
// @Success 200 {object} model.Network
|
||||
// @Router /api/v1/networks/:id [put]
|
||||
func (h *NetworkHandler) UpdateNetwork(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 64)
|
||||
if err != nil {
|
||||
h.logger.Error("解析网络 ID 失败", zap.Error(err))
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的网络 ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var req model.Network
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数错误"})
|
||||
return
|
||||
}
|
||||
|
||||
// 构建更新字段
|
||||
updates := make(map[string]interface{})
|
||||
if req.Name != "" {
|
||||
updates["name"] = req.Name
|
||||
}
|
||||
if req.SubnetIPv4 != "" {
|
||||
updates["subnetIPv4"] = req.SubnetIPv4
|
||||
}
|
||||
if req.SubnetIPv6 != "" {
|
||||
updates["subnetIPv6"] = req.SubnetIPv6
|
||||
}
|
||||
if req.Mode != "" {
|
||||
updates["mode"] = req.Mode
|
||||
}
|
||||
updates["dhcpEnabled"] = req.DHCPEnabled
|
||||
|
||||
// 使用 Service 层更新
|
||||
network, err := h.networkService.UpdateNetwork(id, updates)
|
||||
if err != nil {
|
||||
h.logger.Error("更新网络失败", zap.Error(err))
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("网络更新成功", zap.Uint64("id", id))
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "更新成功",
|
||||
"data": network,
|
||||
})
|
||||
}
|
||||
|
||||
// StartNetwork 启动网络(创建 WG 设备)
|
||||
// @Summary 启动网络
|
||||
// @Tags networks
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "网络 ID"
|
||||
// @Success 200
|
||||
// @Router /api/v1/networks/:id/start [post]
|
||||
func (h *NetworkHandler) StartNetwork(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 64)
|
||||
if err != nil {
|
||||
h.logger.Error("解析网络 ID 失败", zap.Error(err))
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的网络 ID"})
|
||||
return
|
||||
}
|
||||
|
||||
// 使用 Service 层启动网络
|
||||
err = h.networkService.StartNetwork(id)
|
||||
if err != nil {
|
||||
h.logger.Error("启动网络失败", zap.Error(err))
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("网络启动成功", zap.Uint64("id", id))
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "启动成功",
|
||||
})
|
||||
}
|
||||
|
||||
// StopNetwork 停止网络
|
||||
// @Summary 停止网络
|
||||
// @Tags networks
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "网络 ID"
|
||||
// @Success 200
|
||||
// @Router /api/v1/networks/:id/stop [post]
|
||||
func (h *NetworkHandler) StopNetwork(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 64)
|
||||
if err != nil {
|
||||
h.logger.Error("解析网络 ID 失败", zap.Error(err))
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的网络 ID"})
|
||||
return
|
||||
}
|
||||
|
||||
// 使用 Service 层停止网络
|
||||
err = h.networkService.StopNetwork(id)
|
||||
if err != nil {
|
||||
h.logger.Error("停止网络失败", zap.Error(err))
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("网络已停止", zap.Uint64("id", id))
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "停止成功",
|
||||
})
|
||||
}
|
||||
|
||||
// SwitchMode 切换组网模式
|
||||
// @Summary 切换组网模式
|
||||
// @Tags networks
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "网络 ID"
|
||||
// @Param mode body object{mesh_mode string} true "组网模式 (native|enhanced)"
|
||||
// @Success 200
|
||||
// @Router /api/v1/networks/:id/switch-mode [post]
|
||||
func (h *NetworkHandler) SwitchMode(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 64)
|
||||
if err != nil {
|
||||
h.logger.Error("解析网络 ID 失败", zap.Error(err))
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的网络 ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
MeshMode string `json:"mesh_mode"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数错误"})
|
||||
return
|
||||
}
|
||||
|
||||
// 使用 Service 层切换模式
|
||||
err = h.networkService.SwitchMode(id, req.MeshMode)
|
||||
if err != nil {
|
||||
h.logger.Error("切换模式失败", zap.Error(err))
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("模式切换成功", zap.Uint64("id", id), zap.String("mesh_mode", req.MeshMode))
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "切换成功",
|
||||
})
|
||||
}
|
||||
|
||||
// GenerateMeshSeed 生成 MeshSeed
|
||||
// @Summary 生成 MeshSeed
|
||||
// @Tags networks
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "网络 ID"
|
||||
// @Param params body object true "生成参数"
|
||||
// @Success 200
|
||||
// @Router /api/v1/networks/:id/meshseed [post]
|
||||
func (h *NetworkHandler) GenerateMeshSeed(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
networkID, err := strconv.ParseUint(idStr, 10, 64)
|
||||
if err != nil {
|
||||
h.logger.Error("解析网络 ID 失败", zap.Error(err))
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的网络 ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
ExpiresInHours int `json:"expires_in_hours"`
|
||||
MaxUses int `json:"max_uses"`
|
||||
DDNSEnabled bool `json:"ddns_enabled"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数错误"})
|
||||
return
|
||||
}
|
||||
|
||||
// 默认值
|
||||
if req.ExpiresInHours <= 0 {
|
||||
req.ExpiresInHours = 24 // 默认 24 小时
|
||||
}
|
||||
if req.MaxUses <= 0 {
|
||||
req.MaxUses = 10 // 默认 10 次
|
||||
}
|
||||
|
||||
// 计算过期时间
|
||||
expiresAt := time.Now().Add(time.Duration(req.ExpiresInHours) * time.Hour)
|
||||
|
||||
// 调用 MeshSeedService 生成真实的 MeshSeed
|
||||
meshSeed, err := h.meshSeedService.GenerateMeshSeed(networkID, req.MaxUses, expiresAt, req.DDNSEnabled)
|
||||
if err != nil {
|
||||
h.logger.Error("生成 MeshSeed 失败", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// 返回完整的 MeshSeed URL(包含 JoinToken 和 Signature)
|
||||
seedString := "meshray://" + meshSeed.JoinToken + "." + meshSeed.Signature
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": gin.H{
|
||||
"seed_string": seedString,
|
||||
"expires_at": meshSeed.ExpiresAt.Format(time.RFC3339),
|
||||
"max_uses": meshSeed.MaxUses,
|
||||
"ddns_enabled": meshSeed.DDNSEnabled,
|
||||
"used_count": meshSeed.UsedCount,
|
||||
"revoked": meshSeed.Revoked,
|
||||
"issued_at": time.Now().Format(time.RFC3339),
|
||||
"remaining_uses": meshSeed.MaxUses - meshSeed.UsedCount,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// PreviewMeshSeed 预览 MeshSeed 信息
|
||||
// @Summary 预览 MeshSeed
|
||||
// @Tags networks
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param req body object{seed string} true "MeshSeed 字符串"
|
||||
// @Success 200
|
||||
// @Router /api/v1/networks/preview [post]
|
||||
func (h *NetworkHandler) PreviewMeshSeed(c *gin.Context) {
|
||||
var req struct {
|
||||
Seed string `json:"seed"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数错误"})
|
||||
return
|
||||
}
|
||||
|
||||
// 解析 seed_string
|
||||
// 格式: meshray://<joinToken>.<signature>
|
||||
const prefix = "meshray://"
|
||||
if len(req.Seed) <= len(prefix) || string(req.Seed[:len(prefix)]) != prefix {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "MeshSeed 格式不正确"})
|
||||
return
|
||||
}
|
||||
|
||||
seedContent := req.Seed[len(prefix):]
|
||||
parts := strings.SplitN(seedContent, ".", 2)
|
||||
if len(parts) != 2 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "MeshSeed 内容不完整"})
|
||||
return
|
||||
}
|
||||
joinToken, signature := parts[0], parts[1]
|
||||
|
||||
meshSeed, err := h.meshSeedService.VerifyMeshSeed(joinToken, signature)
|
||||
if err != nil {
|
||||
h.logger.Error("MeshSeed 验证失败", zap.Error(err))
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// 查询网络名称和信息
|
||||
network, err := h.networkService.GetNetworkByID(meshSeed.NetworkID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取网络信息失败"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": gin.H{
|
||||
"name": network.Name,
|
||||
"subnet_ipv4": network.SubnetIPv4,
|
||||
"mesh_mode": network.Mode,
|
||||
// 在该应用逻辑中,如果是基于 MeshSeed 加入的设备默认进入待审核状态
|
||||
"require_approval": true,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// JoinNetwork 加入网络
|
||||
// @Summary 加入网络
|
||||
// @Tags networks
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param req body object{seed string, device_name string, message string} true "请求参数"
|
||||
// @Success 200
|
||||
// @Router /api/v1/networks/join [post]
|
||||
func (h *NetworkHandler) JoinNetwork(c *gin.Context) {
|
||||
var req struct {
|
||||
Seed string `json:"seed"`
|
||||
DeviceName string `json:"device_name"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数错误"})
|
||||
return
|
||||
}
|
||||
|
||||
const prefix = "meshray://"
|
||||
if len(req.Seed) <= len(prefix) || string(req.Seed[:len(prefix)]) != prefix {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "MeshSeed 格式不正确"})
|
||||
return
|
||||
}
|
||||
|
||||
seedContent := req.Seed[len(prefix):]
|
||||
parts := strings.SplitN(seedContent, ".", 2)
|
||||
if len(parts) != 2 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "MeshSeed 内容不完整"})
|
||||
return
|
||||
}
|
||||
joinToken, signature := parts[0], parts[1]
|
||||
|
||||
meshSeed, err := h.meshSeedService.VerifyMeshSeed(joinToken, signature)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// 记录一次使用次数
|
||||
if err := h.meshSeedService.IncrementUseCount(meshSeed.SeedID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "处理失败"})
|
||||
return
|
||||
}
|
||||
|
||||
// 在目前的 handler 这里,直接通过 store 存储记录
|
||||
pendingJoin := &model.PendingJoin{
|
||||
SeedID: meshSeed.SeedID,
|
||||
DeviceName: req.DeviceName,
|
||||
RequestIP: c.ClientIP(),
|
||||
Status: "pending",
|
||||
ExpireAt: time.Now().Add(72 * time.Hour), // 72小时过期
|
||||
}
|
||||
|
||||
// 存入数据库
|
||||
db := h.store.DB()
|
||||
if db != nil {
|
||||
db.Create(pendingJoin)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "申请已提交,等待管理员审核",
|
||||
"data": gin.H{
|
||||
"success": true,
|
||||
"needApproval": true,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"git.zkcoi.com/zkcoi/meshray/internal/model"
|
||||
"git.zkcoi.com/zkcoi/meshray/internal/service"
|
||||
sqlite "git.zkcoi.com/zkcoi/meshray/internal/store/sqlite"
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// PendingJoinHandler 待审核 Handler
|
||||
type PendingJoinHandler struct {
|
||||
service *service.PendingJoinService
|
||||
store *sqlite.Store // ← 新增
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewPendingJoinHandler 创建待审核 Handler
|
||||
func NewPendingJoinHandler(service *service.PendingJoinService, store *sqlite.Store, logger *zap.Logger) *PendingJoinHandler {
|
||||
return &PendingJoinHandler{
|
||||
service: service,
|
||||
store: store, // ← 新增
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// ListPendingJoins 获取待审核列表
|
||||
func (h *PendingJoinHandler) ListPendingJoins(c *gin.Context) {
|
||||
// 获取查询参数
|
||||
networkIDStr := c.DefaultQuery("network_id", "0")
|
||||
status := c.DefaultQuery("status", "pending")
|
||||
pageStr := c.DefaultQuery("page", "1")
|
||||
sizeStr := c.DefaultQuery("size", "20")
|
||||
|
||||
// 转换参数
|
||||
networkID, _ := strconv.ParseUint(networkIDStr, 10, 32)
|
||||
page, _ := strconv.Atoi(pageStr)
|
||||
size, _ := strconv.Atoi(sizeStr)
|
||||
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if size < 1 || size > 100 {
|
||||
size = 20
|
||||
}
|
||||
|
||||
// 调用 Service
|
||||
list, total, err := h.service.ListPendingJoins(uint(networkID), status, page, size)
|
||||
if err != nil {
|
||||
h.logger.Error("获取待审核列表失败", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取失败"})
|
||||
return
|
||||
}
|
||||
|
||||
// 转换为响应格式
|
||||
type ResponseItem struct {
|
||||
ID uint `json:"id"`
|
||||
SeedID string `json:"seed_id"`
|
||||
DeviceName string `json:"device_name"`
|
||||
RequestIP string `json:"request_ip"`
|
||||
Status string `json:"status"`
|
||||
ExpireAt string `json:"expire_at"`
|
||||
ApprovedAt string `json:"approved_at"`
|
||||
RejectedAt string `json:"rejected_at"`
|
||||
Reason string `json:"reason"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
NetworkID uint `json:"network_id"`
|
||||
NetworkName string `json:"network_name"`
|
||||
ApplicantIP string `json:"applicant_ip"`
|
||||
ApplyTime string `json:"apply_time"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
var responseList []ResponseItem
|
||||
for _, item := range list {
|
||||
// 通过 SeedID 关联查询 MeshSeed 获取 NetworkID
|
||||
var meshSeed model.MeshSeed
|
||||
networkName := "未知网络"
|
||||
networkID := uint(0)
|
||||
if err := h.store.DB().Where("seed_id = ?", item.SeedID).First(&meshSeed).Error; err == nil {
|
||||
// 查询网络信息
|
||||
var network model.Network
|
||||
if err := h.store.DB().Where("id = ?", meshSeed.NetworkID).First(&network).Error; err == nil {
|
||||
networkName = network.Name
|
||||
networkID = uint(network.ID)
|
||||
}
|
||||
}
|
||||
|
||||
responseList = append(responseList, ResponseItem{
|
||||
ID: item.ID,
|
||||
SeedID: item.SeedID,
|
||||
DeviceName: item.DeviceName,
|
||||
RequestIP: item.RequestIP,
|
||||
Status: item.Status,
|
||||
ExpireAt: item.ExpireAt.Format("2006-01-02 15:04:05"),
|
||||
ApprovedAt: formatTime(item.ApprovedAt),
|
||||
RejectedAt: formatTime(item.RejectedAt),
|
||||
Reason: item.Reason,
|
||||
CreatedAt: item.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||
UpdatedAt: item.UpdatedAt.Format("2006-01-02 15:04:05"),
|
||||
NetworkID: networkID,
|
||||
NetworkName: networkName,
|
||||
ApplicantIP: item.RequestIP,
|
||||
ApplyTime: item.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||
Message: "",
|
||||
})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": gin.H{
|
||||
"list": responseList,
|
||||
"total": total,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ApproveJoin 审核通过
|
||||
func (h *PendingJoinHandler) ApproveJoin(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, _ := strconv.ParseUint(idStr, 10, 32)
|
||||
|
||||
result, err := h.service.ApproveJoin(uint(id))
|
||||
if err != nil {
|
||||
h.logger.Error("审核通过失败", zap.Error(err))
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// 返回完整配置给前端
|
||||
h.logger.Info("审核通过成功",
|
||||
zap.Uint64("id", uint64(id)),
|
||||
zap.String("device_name", result.Device.Name),
|
||||
zap.String("device_ip", result.Device.VirtualIP))
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "审核通过",
|
||||
"data": gin.H{
|
||||
"device": result.Device,
|
||||
"private_key": result.PrivateKey,
|
||||
"network": result.Network,
|
||||
"wireguard_config": result.ConfigText,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// RejectJoin 审核拒绝
|
||||
func (h *PendingJoinHandler) RejectJoin(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, _ := strconv.ParseUint(idStr, 10, 32)
|
||||
|
||||
var req struct {
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.service.RejectJoin(uint(id), req.Reason); err != nil {
|
||||
h.logger.Error("审核拒绝失败", zap.Error(err))
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "已拒绝"})
|
||||
}
|
||||
|
||||
// CountPending 统计待审核数量
|
||||
func (h *PendingJoinHandler) CountPending(c *gin.Context) {
|
||||
count, err := h.service.CountPending()
|
||||
if err != nil {
|
||||
h.logger.Error("统计待审核数量失败", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "统计失败"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": gin.H{
|
||||
"count": count,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteExpired 清理过期记录
|
||||
func (h *PendingJoinHandler) DeleteExpired(c *gin.Context) {
|
||||
if err := h.service.DeleteExpired(); err != nil {
|
||||
h.logger.Error("清理过期记录失败", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "清理失败"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "清理完成"})
|
||||
}
|
||||
|
||||
func formatTime(t *time.Time) string {
|
||||
if t == nil {
|
||||
return ""
|
||||
}
|
||||
return t.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"git.zkcoi.com/zkcoi/meshray/internal/api/dto"
|
||||
"git.zkcoi.com/zkcoi/meshray/internal/model"
|
||||
"git.zkcoi.com/zkcoi/meshray/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// PolicyHandler 策略管理 Handler
|
||||
type PolicyHandler struct {
|
||||
policyService *service.PolicyService
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewPolicyHandler 创建 Policy Handler
|
||||
func NewPolicyHandler(policyService *service.PolicyService, logger *zap.Logger) *PolicyHandler {
|
||||
return &PolicyHandler{
|
||||
policyService: policyService,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// ListPolicies 获取策略列表
|
||||
func (h *PolicyHandler) ListPolicies(c *gin.Context) {
|
||||
policies, err := h.policyService.ListPolicies()
|
||||
if err != nil {
|
||||
h.logger.Error("获取策略列表失败", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取策略列表失败"})
|
||||
return
|
||||
}
|
||||
// 使用 DTO 批量转换
|
||||
respList := dto.ToPolicyResponseList(policies)
|
||||
c.JSON(http.StatusOK, gin.H{"data": respList})
|
||||
}
|
||||
|
||||
// GetPolicy 获取策略详情
|
||||
func (h *PolicyHandler) GetPolicy(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的 ID"})
|
||||
return
|
||||
}
|
||||
|
||||
policy, err := h.policyService.GetPolicyByID(uint(id))
|
||||
if err != nil {
|
||||
h.logger.Error("获取策略失败", zap.Error(err))
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
// 使用 DTO 转换
|
||||
resp := dto.ToPolicyResponse(policy)
|
||||
c.JSON(http.StatusOK, gin.H{"data": resp})
|
||||
}
|
||||
|
||||
// CreatePolicy 创建策略
|
||||
func (h *PolicyHandler) CreatePolicy(c *gin.Context) {
|
||||
var req model.Policy
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数错误"})
|
||||
return
|
||||
}
|
||||
|
||||
policy, err := h.policyService.CreatePolicy(&req)
|
||||
if err != nil {
|
||||
h.logger.Error("创建策略失败", zap.Error(err))
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("策略创建成功", zap.String("name", policy.Name))
|
||||
|
||||
// 使用 DTO 转换
|
||||
resp := dto.ToPolicyResponse(policy)
|
||||
c.JSON(http.StatusOK, gin.H{"data": resp})
|
||||
}
|
||||
|
||||
// UpdatePolicy 更新策略
|
||||
func (h *PolicyHandler) UpdatePolicy(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的 ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var updates map[string]interface{}
|
||||
if err := c.ShouldBindJSON(&updates); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数错误"})
|
||||
return
|
||||
}
|
||||
|
||||
policy, err := h.policyService.UpdatePolicy(uint(id), updates)
|
||||
if err != nil {
|
||||
h.logger.Error("更新策略失败", zap.Error(err))
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("策略更新成功", zap.Uint64("id", policy.ID))
|
||||
|
||||
// 使用 DTO 转换
|
||||
resp := dto.ToPolicyResponse(policy)
|
||||
c.JSON(http.StatusOK, gin.H{"data": resp})
|
||||
}
|
||||
|
||||
// DeletePolicy 删除策略
|
||||
func (h *PolicyHandler) DeletePolicy(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的 ID"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.policyService.DeletePolicy(uint(id)); err != nil {
|
||||
h.logger.Error("删除策略失败", zap.Error(err))
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("策略删除成功", zap.Uint64("id", id))
|
||||
c.JSON(http.StatusOK, gin.H{"message": "策略已删除"})
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.zkcoi.com/zkcoi/meshray/internal/model"
|
||||
"git.zkcoi.com/zkcoi/meshray/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// ServiceHandler 服务管理 Handler
|
||||
type ServiceHandler struct {
|
||||
serviceService *service.ServiceService
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewServiceHandler 创建 Service Handler
|
||||
func NewServiceHandler(serviceService *service.ServiceService, logger *zap.Logger) *ServiceHandler {
|
||||
return &ServiceHandler{
|
||||
serviceService: serviceService,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// ListServices 获取服务列表
|
||||
func (h *ServiceHandler) ListServices(c *gin.Context) {
|
||||
serverType := c.Query("type")
|
||||
services, err := h.serviceService.ListServices(serverType)
|
||||
if err != nil {
|
||||
h.logger.Error("获取服务列表失败", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取服务列表失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": services})
|
||||
}
|
||||
|
||||
// GetService 获取服务详情
|
||||
func (h *ServiceHandler) GetService(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
if id == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "缺少 ID"})
|
||||
return
|
||||
}
|
||||
|
||||
service, err := h.serviceService.GetServiceByID(id)
|
||||
if err != nil {
|
||||
h.logger.Error("获取服务失败", zap.Error(err))
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": service})
|
||||
}
|
||||
|
||||
// CreateService 创建服务
|
||||
func (h *ServiceHandler) CreateService(c *gin.Context) {
|
||||
var req model.Service
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数错误"})
|
||||
return
|
||||
}
|
||||
|
||||
service, err := h.serviceService.CreateService(&req)
|
||||
if err != nil {
|
||||
h.logger.Error("创建服务失败", zap.Error(err))
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("服务创建成功", zap.String("name", service.Name))
|
||||
c.JSON(http.StatusOK, gin.H{"data": service})
|
||||
}
|
||||
|
||||
// UpdateService 更新服务
|
||||
func (h *ServiceHandler) UpdateService(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
if id == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "缺少 ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var updates map[string]interface{}
|
||||
if err := c.ShouldBindJSON(&updates); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数错误"})
|
||||
return
|
||||
}
|
||||
|
||||
service, err := h.serviceService.UpdateService(id, updates)
|
||||
if err != nil {
|
||||
h.logger.Error("更新服务失败", zap.Error(err))
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("服务更新成功", zap.String("id", service.ID))
|
||||
c.JSON(http.StatusOK, gin.H{"data": service})
|
||||
}
|
||||
|
||||
// DeleteService 删除服务
|
||||
func (h *ServiceHandler) DeleteService(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
if id == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "缺少 ID"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.serviceService.DeleteService(id); err != nil {
|
||||
h.logger.Error("删除服务失败", zap.Error(err))
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("服务删除成功", zap.String("id", id))
|
||||
c.JSON(http.StatusOK, gin.H{"message": "服务已删除"})
|
||||
}
|
||||
|
||||
// TestServiceConnectivity 测试服务连通性
|
||||
func (h *ServiceHandler) TestServiceConnectivity(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
if id == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "缺少 ID"})
|
||||
return
|
||||
}
|
||||
|
||||
status, err := h.serviceService.TestServiceConnectivity(id)
|
||||
if err != nil {
|
||||
h.logger.Error("测试连通性失败", zap.Error(err))
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"data": gin.H{"status": status}})
|
||||
}
|
||||
|
||||
// GetServiceSchema 获取服务 Schema(支持的协议类型)
|
||||
func (h *ServiceHandler) GetServiceSchema(c *gin.Context) {
|
||||
schema := gin.H{
|
||||
"protocols": []gin.H{
|
||||
{
|
||||
"type": "http",
|
||||
"label": "HTTP 服务",
|
||||
"description": "超文本传输协议,用于 Web 服务",
|
||||
"defaultPort": 80,
|
||||
},
|
||||
{
|
||||
"type": "https",
|
||||
"label": "HTTPS 服务",
|
||||
"description": "安全的 HTTP 协议,用于加密 Web 服务",
|
||||
"defaultPort": 443,
|
||||
},
|
||||
{
|
||||
"type": "tcp",
|
||||
"label": "TCP 服务",
|
||||
"description": "传输控制协议,面向连接的可靠传输",
|
||||
"defaultPort": 0,
|
||||
},
|
||||
{
|
||||
"type": "udp",
|
||||
"label": "UDP 服务",
|
||||
"description": "用户数据报协议,无连接的快速传输",
|
||||
"defaultPort": 0,
|
||||
},
|
||||
{
|
||||
"type": "ssh",
|
||||
"label": "SSH 服务",
|
||||
"description": "安全外壳协议,用于远程登录",
|
||||
"defaultPort": 22,
|
||||
},
|
||||
{
|
||||
"type": "rdp",
|
||||
"label": "RDP 服务",
|
||||
"description": "远程桌面协议,用于 Windows 远程桌面",
|
||||
"defaultPort": 3389,
|
||||
},
|
||||
{
|
||||
"type": "vnc",
|
||||
"label": "VNC 服务",
|
||||
"description": "虚拟网络计算,用于图形化远程桌面",
|
||||
"defaultPort": 5900,
|
||||
},
|
||||
{
|
||||
"type": "custom",
|
||||
"label": "自定义服务",
|
||||
"description": "用户自定义的其他服务类型",
|
||||
"defaultPort": 0,
|
||||
},
|
||||
},
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": schema})
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.zkcoi.com/zkcoi/meshray/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// SettingsHandler 系统设置 Handler
|
||||
type SettingsHandler struct {
|
||||
logger *zap.Logger
|
||||
settingsService *service.SettingsService
|
||||
}
|
||||
|
||||
// NewSettingsHandler 创建系统设置 Handler
|
||||
func NewSettingsHandler(settingsService *service.SettingsService, logger *zap.Logger) *SettingsHandler {
|
||||
return &SettingsHandler{
|
||||
logger: logger,
|
||||
settingsService: settingsService,
|
||||
}
|
||||
}
|
||||
|
||||
// GetSettings 获取系统设置
|
||||
func (h *SettingsHandler) GetSettings(c *gin.Context) {
|
||||
setting, err := h.settingsService.GetSettings()
|
||||
if err != nil {
|
||||
h.logger.Error("获取系统设置失败", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "获取设置失败",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": setting,
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateSettings 更新系统设置
|
||||
func (h *SettingsHandler) UpdateSettings(c *gin.Context) {
|
||||
var updates map[string]interface{}
|
||||
if err := c.ShouldBindJSON(&updates); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "参数错误",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
updatedSetting, err := h.settingsService.UpdateSettings(updates)
|
||||
if err != nil {
|
||||
h.logger.Error("更新系统设置失败", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "更新失败",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("系统设置已更新", zap.Any("updates", updates))
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "设置已保存",
|
||||
"data": updatedSetting,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"time"
|
||||
|
||||
"git.zkcoi.com/zkcoi/meshray/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// SystemConfigHandler 系统配置处理器
|
||||
type SystemConfigHandler struct {
|
||||
configService *service.SystemConfigService
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewSystemConfigHandler 创建系统配置处理器
|
||||
func NewSystemConfigHandler(configService *service.SystemConfigService, logger *zap.Logger) *SystemConfigHandler {
|
||||
return &SystemConfigHandler{
|
||||
configService: configService,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// GetWGMode 获取当前 WG 运行模式
|
||||
// @Summary 获取当前 WG 运行模式
|
||||
// @Tags system-config
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {object} object{wg_mode=string,wg_mode_display=string,actual_mode=string}
|
||||
// @Router /api/v1/system-config/wg-mode [get]
|
||||
func (h *SystemConfigHandler) GetWGMode(c *gin.Context) {
|
||||
// 获取配置的模式(用户设置)
|
||||
configMode, err := h.configService.GetWGMode()
|
||||
if err != nil {
|
||||
h.logger.Error("获取 WG 配置失败", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取失败"})
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ 固定使用用户态模式
|
||||
actualMode := "userspace"
|
||||
h.logger.Debug("WG 模式:固定使用 wireguard-go 用户态")
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"wg_mode": configMode, // 配置的模式(已废弃,保留兼容)
|
||||
"wg_mode_display": getModeDisplay(actualMode), // 实际运行的模式显示
|
||||
"actual_mode": actualMode, // 实际运行的模式代码
|
||||
})
|
||||
}
|
||||
|
||||
// getModeDisplay 获取模式的中文显示
|
||||
func getModeDisplay(mode string) string {
|
||||
switch mode {
|
||||
case "kernel":
|
||||
return "内核态"
|
||||
case "userspace":
|
||||
return "用户态"
|
||||
default:
|
||||
return mode
|
||||
}
|
||||
}
|
||||
|
||||
// SetWGMode 设置 WG 运行模式
|
||||
// @Summary 设置 WG 运行模式(需要重启 MeshRay 才能生效)
|
||||
// @Tags system-config
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param mode body object{mode string} true "WG 模式 (auto|kernel|userspace)"
|
||||
// @Success 200 {object} object{message=string}
|
||||
// @Router /api/v1/system-config/wg-mode [put]
|
||||
func (h *SystemConfigHandler) SetWGMode(c *gin.Context) {
|
||||
var req struct {
|
||||
Mode string `json:"mode"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数错误"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.configService.SetWGMode(req.Mode); err != nil {
|
||||
h.logger.Error("设置 WG 模式失败", zap.Error(err))
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("WG 模式已更新,需要重启 MeshRay 才能生效", zap.String("mode", req.Mode))
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "设置成功,请重启 MeshRay 使新配置生效",
|
||||
"wg_mode": req.Mode,
|
||||
})
|
||||
}
|
||||
|
||||
// ==================== 系统运维功能(P1/P2) ====================
|
||||
|
||||
// ChangePasswordRequest 修改密码请求
|
||||
type ChangePasswordRequest struct {
|
||||
OldPassword string `json:"old_password"`
|
||||
NewPassword string `json:"new_password"`
|
||||
}
|
||||
|
||||
// ChangePassword 修改密码(P1)
|
||||
func (h *SystemConfigHandler) ChangePassword(c *gin.Context) {
|
||||
var req ChangePasswordRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数错误"})
|
||||
return
|
||||
}
|
||||
|
||||
// 验证密码长度
|
||||
if len(req.NewPassword) < 6 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "密码至少 6 个字符"})
|
||||
return
|
||||
}
|
||||
|
||||
// 从上下文获取用户 ID
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "未认证"})
|
||||
return
|
||||
}
|
||||
|
||||
// 调用服务层修改密码
|
||||
err := h.configService.ChangePassword(userID.(uint), req.OldPassword, req.NewPassword)
|
||||
if err != nil {
|
||||
h.logger.Error("修改密码失败", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("密码修改成功", zap.Uint("user_id", userID.(uint)))
|
||||
c.JSON(http.StatusOK, gin.H{"message": "密码修改成功"})
|
||||
}
|
||||
|
||||
// RestartCoreRequest 重启核心服务请求
|
||||
type RestartCoreRequest struct {
|
||||
Graceful bool `json:"graceful"` // 是否优雅重启
|
||||
}
|
||||
|
||||
// RestartCore 重启核心服务(P1 - Windows 下通过重启进程实现)
|
||||
func (h *SystemConfigHandler) RestartCore(c *gin.Context) {
|
||||
var req RestartCoreRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数错误"})
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("收到重启核心服务请求", zap.Bool("graceful", req.Graceful))
|
||||
|
||||
// 获取当前可执行文件路径
|
||||
execPath, err := os.Executable()
|
||||
if err != nil {
|
||||
h.logger.Error("获取可执行文件路径失败", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "无法获取程序路径"})
|
||||
return
|
||||
}
|
||||
|
||||
// 在后台启动新进程
|
||||
cmd := exec.Command(execPath)
|
||||
if err := cmd.Start(); err != nil {
|
||||
h.logger.Error("启动新进程失败", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "重启失败"})
|
||||
return
|
||||
}
|
||||
|
||||
// 延迟退出,让新进程有时间启动
|
||||
go func() {
|
||||
time.Sleep(2 * time.Second)
|
||||
os.Exit(0)
|
||||
}()
|
||||
|
||||
h.logger.Info("核心服务将在 2 秒后重启")
|
||||
c.JSON(http.StatusOK, gin.H{"message": "核心服务正在重启..."})
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.zkcoi.com/zkcoi/meshray/internal/ctr"
|
||||
"git.zkcoi.com/zkcoi/meshray/internal/model"
|
||||
"git.zkcoi.com/zkcoi/meshray/internal/store/sqlite"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gorilla/websocket"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
var upgrader = websocket.Upgrader{
|
||||
// ✅ 修复:限制 WebSocket 跨域,只允许特定来源
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
origin := r.Header.Get("Origin")
|
||||
allowedOrigins := map[string]bool{
|
||||
"http://localhost:9531": true,
|
||||
"http://127.0.0.1:9531": true,
|
||||
}
|
||||
return allowedOrigins[origin]
|
||||
},
|
||||
}
|
||||
|
||||
// WSMessage 与前端定义一致的结构
|
||||
type WSMessage struct {
|
||||
Channel string `json:"channel"`
|
||||
Type string `json:"type"`
|
||||
Payload interface{} `json:"payload"`
|
||||
}
|
||||
|
||||
// WSHandler 处理 WebSocket 连接
|
||||
type WSHandler struct {
|
||||
ctrClient *ctr.Ctr
|
||||
logger *zap.Logger
|
||||
store *sqlite.Store
|
||||
}
|
||||
|
||||
func NewWSHandler(ctrClient *ctr.Ctr, store *sqlite.Store, logger *zap.Logger) *WSHandler {
|
||||
return &WSHandler{
|
||||
ctrClient: ctrClient,
|
||||
logger: logger,
|
||||
store: store,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *WSHandler) ServeWS(c *gin.Context) {
|
||||
ws, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
||||
if err != nil {
|
||||
h.logger.Error("升级 WebSocket 失败", zap.Error(err))
|
||||
return
|
||||
}
|
||||
defer ws.Close()
|
||||
|
||||
h.logger.Info("客户端已连接 WebSocket")
|
||||
|
||||
// 这里暂时用简单轮询的方案,每秒向终端推送 core metrics 和 peering 状态
|
||||
// 在生产级应用中,可以使用 EventBus 订阅发布模式
|
||||
ticker := time.NewTicker(1 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
// 启动一个 goroutine 读取客户端发来的控制指令 (ping/subscribe)
|
||||
go func() {
|
||||
for {
|
||||
_, msg, err := ws.ReadMessage()
|
||||
if err != nil {
|
||||
h.logger.Debug("WebSocket 客户端断开", zap.Error(err))
|
||||
break
|
||||
}
|
||||
|
||||
// 简单响应 ping
|
||||
var req map[string]interface{}
|
||||
if err := json.Unmarshal(msg, &req); err == nil {
|
||||
if req["type"] == "ping" {
|
||||
_ = ws.WriteJSON(map[string]interface{}{"type": "pong", "timestamp": time.Now().Unix()})
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
// 1. 获取所有组网
|
||||
var networks []model.Network
|
||||
if err := h.store.DB().Find(&networks).Error; err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// 聚合数据
|
||||
var totalBytesSent, totalBytesRecv uint64
|
||||
var totalFallbacks int64
|
||||
var totalActiveConnections int64
|
||||
|
||||
// 收集链路分布 (Layer -> Count)
|
||||
layerDistribution := make(map[string]int)
|
||||
|
||||
// wg 状态聚合(设备是否在运行)
|
||||
wgRunningCount := 0
|
||||
|
||||
// 2. 遍历所有组网聚合 Ctr Status
|
||||
for _, net := range networks {
|
||||
status, err := h.ctrClient.GetStatus(net.ID)
|
||||
if err != nil || status == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if status.WGStatus != nil && status.WGStatus.Running {
|
||||
wgRunningCount++
|
||||
}
|
||||
|
||||
if status.CoreStatus != nil {
|
||||
totalBytesSent += status.CoreStatus.BytesSent
|
||||
totalBytesRecv += status.CoreStatus.BytesReceived
|
||||
totalFallbacks += status.CoreStatus.StrategyFallbacks
|
||||
totalActiveConnections += status.CoreStatus.ActiveConnections
|
||||
|
||||
for _, peerStatus := range status.CoreStatus.Peers {
|
||||
if peerStatus.Connected && peerStatus.Layer != "" {
|
||||
layerDistribution[peerStatus.Layer]++
|
||||
} else if peerStatus.Connected {
|
||||
layerDistribution["Native"]++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 构建 payload 推送
|
||||
msg := WSMessage{
|
||||
Channel: "metrics",
|
||||
Type: "update",
|
||||
Payload: map[string]interface{}{
|
||||
"summary": map[string]interface{}{
|
||||
"networks_count": len(networks),
|
||||
"wg_running": wgRunningCount,
|
||||
},
|
||||
"core_status": map[string]interface{}{
|
||||
"bytes_sent": totalBytesSent,
|
||||
"bytes_received": totalBytesRecv,
|
||||
"strategy_fallbacks": totalFallbacks,
|
||||
"active_connections": totalActiveConnections,
|
||||
"layer_distribution": layerDistribution,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if err := ws.WriteJSON(msg); err != nil {
|
||||
return
|
||||
}
|
||||
case <-c.Request.Context().Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.zkcoi.com/zkcoi/meshray/internal/service"
|
||||
"git.zkcoi.com/zkcoi/meshray/internal/store/sqlite"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Claims JWT 声明
|
||||
type Claims struct {
|
||||
UserID uint `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
TokenType string `json:"token_type"` // access | refresh
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
// Response 统一响应格式
|
||||
type Response struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
// Success 成功响应
|
||||
func Success(c *gin.Context, data interface{}) {
|
||||
c.JSON(http.StatusOK, Response{
|
||||
Code: 0,
|
||||
Message: "success",
|
||||
Data: data,
|
||||
})
|
||||
}
|
||||
|
||||
// Error 错误响应
|
||||
func Error(c *gin.Context, code int, message string) {
|
||||
c.JSON(http.StatusOK, Response{
|
||||
Code: code,
|
||||
Message: message,
|
||||
})
|
||||
}
|
||||
|
||||
// GenerateToken 生成 JWT Token
|
||||
func GenerateToken(secret string, userID uint, username string, tokenType string, duration time.Duration) (string, error) {
|
||||
claims := Claims{
|
||||
UserID: userID,
|
||||
Username: username,
|
||||
TokenType: tokenType,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(duration)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
Issuer: "meshray",
|
||||
},
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString([]byte(secret))
|
||||
}
|
||||
|
||||
// JWTAuth JWT 鉴权中间件
|
||||
func JWTAuth(secret string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var tokenString string
|
||||
|
||||
// 1. 优先从 Authorization Header 获取
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader != "" {
|
||||
parts := strings.SplitN(authHeader, " ", 2)
|
||||
if len(parts) == 2 && parts[0] == "Bearer" {
|
||||
tokenString = parts[1]
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 如果 Header 中没有,尝试从 query 参数获取(WebSocket 场景)
|
||||
if tokenString == "" {
|
||||
tokenString = c.Query("token")
|
||||
}
|
||||
|
||||
// 3. 都没有则拒绝
|
||||
if tokenString == "" {
|
||||
Error(c, 401, "未提供认证令牌")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// 解析和验证 Token
|
||||
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
|
||||
return []byte(secret), nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
Error(c, 401, "无效的认证令牌")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
if !token.Valid {
|
||||
Error(c, 401, "认证令牌已过期或无效")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(*Claims)
|
||||
if !ok {
|
||||
Error(c, 401, "无法解析认证声明")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// 检查 Token 类型
|
||||
if claims.TokenType != "access" {
|
||||
Error(c, 403, "Token 类型错误")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// 将用户信息存入上下文
|
||||
c.Set("user_id", claims.UserID)
|
||||
c.Set("username", claims.Username)
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// LoginRequest 登录请求
|
||||
type LoginRequest struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
|
||||
// LoginResponse 登录响应
|
||||
type LoginResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresIn int64 `json:"expires_in"`
|
||||
}
|
||||
|
||||
// LoginHandler 登录 Handler
|
||||
func LoginHandler(jwtSecret string, logger *zap.Logger, store *sqlite.Store) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req LoginRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
Error(c, 400, "请求参数错误")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
logger.Info("Login request received",
|
||||
zap.String("username", req.Username))
|
||||
|
||||
// 创建 UserService 并验证用户
|
||||
userService := service.NewUserService(store)
|
||||
user, err := userService.Authenticate(req.Username, req.Password)
|
||||
if err != nil {
|
||||
logger.Warn("Authentication failed",
|
||||
zap.String("username", req.Username),
|
||||
zap.Error(err))
|
||||
Error(c, 401, err.Error())
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// 生成 Access Token
|
||||
token, err := GenerateToken(
|
||||
jwtSecret,
|
||||
user.ID,
|
||||
user.Username,
|
||||
"access",
|
||||
2*time.Hour,
|
||||
)
|
||||
if err != nil {
|
||||
logger.Error("Failed to generate token", zap.Error(err))
|
||||
Error(c, 500, "无法生成访问令牌")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// 生成 Refresh Token
|
||||
refreshToken, err := GenerateToken(
|
||||
jwtSecret,
|
||||
user.ID,
|
||||
user.Username,
|
||||
"refresh",
|
||||
7*24*time.Hour,
|
||||
)
|
||||
if err != nil {
|
||||
logger.Error("Failed to generate refresh token", zap.Error(err))
|
||||
Error(c, 500, "无法生成刷新令牌")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
logger.Info("User login successful",
|
||||
zap.String("username", user.Username),
|
||||
zap.Uint("user_id", user.ID))
|
||||
|
||||
Success(c, LoginResponse{
|
||||
AccessToken: token,
|
||||
RefreshToken: refreshToken,
|
||||
ExpiresIn: 7200,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// RefreshTokenRequest 刷新 Token 请求
|
||||
type RefreshTokenRequest struct {
|
||||
RefreshToken string `json:"refresh_token" binding:"required"`
|
||||
}
|
||||
|
||||
// RefreshTokenHandler 刷新 Token Handler
|
||||
func RefreshTokenHandler(jwtSecret string, logger *zap.Logger) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req RefreshTokenRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
Error(c, 400, "请求参数错误")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
logger.Info("Refresh token request received")
|
||||
|
||||
// 验证刷新 Token
|
||||
token, err := jwt.ParseWithClaims(req.RefreshToken, &Claims{}, func(token *jwt.Token) (interface{}, error) {
|
||||
return []byte(jwtSecret), nil
|
||||
})
|
||||
|
||||
if err != nil || !token.Valid {
|
||||
Error(c, 401, "无效的刷新令牌")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(*Claims)
|
||||
if !ok || claims.TokenType != "refresh" {
|
||||
Error(c, 403, "Token 类型错误")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// 生成新的访问令牌
|
||||
newAccessToken, err := GenerateToken(
|
||||
jwtSecret,
|
||||
claims.UserID,
|
||||
claims.Username,
|
||||
"access",
|
||||
2*time.Hour,
|
||||
)
|
||||
if err != nil {
|
||||
logger.Error("Failed to generate new access token", zap.Error(err))
|
||||
Error(c, 500, "无法生成访问令牌")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
Success(c, gin.H{
|
||||
"access_token": newAccessToken,
|
||||
"expires_in": 7200,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// RequestLogger 请求日志中间件
|
||||
func RequestLogger(logger *zap.Logger) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
start := time.Now()
|
||||
path := c.Request.URL.Path
|
||||
query := c.Request.URL.RawQuery
|
||||
|
||||
c.Next()
|
||||
|
||||
latency := time.Since(start)
|
||||
statusCode := c.Writer.Status()
|
||||
|
||||
logger.Info("HTTP request",
|
||||
zap.Int("status", statusCode),
|
||||
zap.String("method", c.Request.Method),
|
||||
zap.String("path", path),
|
||||
zap.String("query", query),
|
||||
zap.String("ip", c.ClientIP()),
|
||||
zap.String("user_agent", c.Request.UserAgent()),
|
||||
zap.Duration("latency", latency),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// CORS CORS 中间件
|
||||
func CORS() gin.HandlerFunc {
|
||||
// 允许的 Origin 白名单
|
||||
allowedOrigins := map[string]bool{
|
||||
"http://localhost:9531": true,
|
||||
"http://127.0.0.1:9531": true,
|
||||
// 生产环境可以添加域名
|
||||
// "https://meshray.example.com": true,
|
||||
}
|
||||
|
||||
return func(c *gin.Context) {
|
||||
origin := c.Request.Header.Get("Origin")
|
||||
|
||||
// ✅ 修复:检查 Origin 是否在白名单内
|
||||
if !allowedOrigins[origin] {
|
||||
// 不在白名单,使用默认值(不设置 Access-Control-Allow-Origin)
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
// 在白名单内,设置 CORS 头
|
||||
c.Writer.Header().Set("Access-Control-Allow-Origin", origin)
|
||||
c.Writer.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||
c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With")
|
||||
c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT, DELETE")
|
||||
|
||||
if c.Request.Method == "OPTIONS" {
|
||||
c.AbortWithStatus(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// InternalError 内部错误
|
||||
var InternalError = errors.New("内部错误")
|
||||
@@ -0,0 +1,532 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.zkcoi.com/zkcoi/meshray/internal/api/handler"
|
||||
"git.zkcoi.com/zkcoi/meshray/internal/api/middleware"
|
||||
"git.zkcoi.com/zkcoi/meshray/internal/config"
|
||||
"git.zkcoi.com/zkcoi/meshray/internal/ctr"
|
||||
corehandler "git.zkcoi.com/zkcoi/meshray/internal/handler"
|
||||
"git.zkcoi.com/zkcoi/meshray/internal/model"
|
||||
"git.zkcoi.com/zkcoi/meshray/internal/service"
|
||||
"git.zkcoi.com/zkcoi/meshray/internal/store/sqlite"
|
||||
"git.zkcoi.com/zkcoi/meshray/web"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/shirou/gopsutil/v4/cpu"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Server API 服务器
|
||||
type Server struct {
|
||||
engine *gin.Engine
|
||||
config *config.Config
|
||||
logger *zap.Logger
|
||||
store *sqlite.Store
|
||||
ctrClient *ctr.Ctr // 全局 Ctr 实例
|
||||
}
|
||||
|
||||
// NewServer 创建 API 服务器
|
||||
func NewServer(cfg *config.Config, logger *zap.Logger, dbStore *sqlite.Store) (*Server, error) {
|
||||
// 设置 Gin 模式
|
||||
switch cfg.Server.Mode {
|
||||
case "debug":
|
||||
gin.SetMode(gin.DebugMode)
|
||||
case "release":
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
default:
|
||||
gin.SetMode(gin.TestMode)
|
||||
}
|
||||
|
||||
engine := gin.New()
|
||||
|
||||
server := &Server{
|
||||
engine: engine,
|
||||
config: cfg,
|
||||
logger: logger,
|
||||
store: dbStore,
|
||||
}
|
||||
|
||||
// 注册中间件
|
||||
server.registerMiddleware()
|
||||
|
||||
// 注册路由
|
||||
if err := server.registerRoutes(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return server, nil
|
||||
}
|
||||
|
||||
// registerMiddleware 注册全局中间件
|
||||
func (s *Server) registerMiddleware() {
|
||||
// 恢复中间件
|
||||
s.engine.Use(gin.Recovery())
|
||||
|
||||
// 请求日志中间件
|
||||
s.engine.Use(middleware.RequestLogger(s.logger))
|
||||
|
||||
// CORS 中间件(开发环境)
|
||||
if s.config.Server.Mode != "release" {
|
||||
s.engine.Use(middleware.CORS())
|
||||
}
|
||||
}
|
||||
|
||||
// registerRoutes 注册所有路由
|
||||
func (s *Server) registerRoutes() error {
|
||||
// 健康检查
|
||||
s.engine.GET("/health", func(c *gin.Context) {
|
||||
c.JSON(200, gin.H{"status": "ok"})
|
||||
})
|
||||
|
||||
// 静态文件服务(前端)
|
||||
// 使用 embed 的静态文件
|
||||
var staticFS fs.FS
|
||||
var useEmbed bool
|
||||
|
||||
// 直接使用 web 包中的 WebAssets,剥离外层的 static 目录
|
||||
if embedFS, err := fs.Sub(web.WebAssets, "static"); err == nil {
|
||||
// 检查是否有 index.html
|
||||
if _, statErr := fs.Stat(embedFS, "index.html"); statErr == nil {
|
||||
staticFS = embedFS
|
||||
useEmbed = true
|
||||
s.logger.Info("使用内嵌的静态文件")
|
||||
} else {
|
||||
s.logger.Warn("embed 中找不到 index.html", zap.Error(statErr))
|
||||
}
|
||||
} else {
|
||||
s.logger.Warn("embed 文件系统创建失败", zap.Error(err))
|
||||
}
|
||||
|
||||
// 如果 embed 没有内容,使用外部目录
|
||||
if !useEmbed && s.config.Server.StaticPath != "" {
|
||||
staticFS = os.DirFS(s.config.Server.StaticPath)
|
||||
s.logger.Info("使用外部静态文件目录", zap.String("path", s.config.Server.StaticPath))
|
||||
}
|
||||
|
||||
if staticFS != nil {
|
||||
// ✅ 注册根路径处理
|
||||
s.engine.GET("/", func(c *gin.Context) {
|
||||
file, err := staticFS.Open("index.html")
|
||||
if err == nil {
|
||||
defer file.Close()
|
||||
content, _ := io.ReadAll(file)
|
||||
c.Data(200, "text/html; charset=utf-8", content)
|
||||
return
|
||||
}
|
||||
c.String(500, "Failed to load index.html")
|
||||
})
|
||||
|
||||
// ✅ 简化:只注册 NoRoute 处理 SPA 路由
|
||||
s.engine.NoRoute(func(c *gin.Context) {
|
||||
path := c.Request.URL.Path
|
||||
|
||||
// 如果是 API 请求,返回 404
|
||||
if strings.HasPrefix(path, "/api/") {
|
||||
c.JSON(404, gin.H{"error": "API not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// 尝试直接提供文件
|
||||
filePath := strings.TrimPrefix(path, "/")
|
||||
if filePath == "" {
|
||||
filePath = "index.html"
|
||||
}
|
||||
|
||||
// 读取并返回文件内容
|
||||
file, err := staticFS.Open(filePath)
|
||||
if err == nil {
|
||||
defer file.Close()
|
||||
content, _ := io.ReadAll(file)
|
||||
c.Data(200, getContentType(filePath), content)
|
||||
return
|
||||
}
|
||||
|
||||
// 其他请求都返回 index.html(SPA 路由支持)
|
||||
file, _ = staticFS.Open("index.html")
|
||||
if file != nil {
|
||||
defer file.Close()
|
||||
content, _ := io.ReadAll(file)
|
||||
c.Data(200, "text/html; charset=utf-8", content)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
s.logger.Warn("未配置静态文件路径,前端将不可用")
|
||||
}
|
||||
|
||||
// API v1 路由组
|
||||
v1 := s.engine.Group("/api/v1")
|
||||
{
|
||||
// 公开接口(无需鉴权)
|
||||
public := v1.Group("")
|
||||
{
|
||||
public.POST("/auth/login", middleware.LoginHandler(s.config.JWT.Secret, s.logger, s.store))
|
||||
public.POST("/auth/refresh", middleware.RefreshTokenHandler(s.config.JWT.Secret, s.logger))
|
||||
}
|
||||
|
||||
// ✅ 提前初始化 ctrClient(在路由注册前)
|
||||
var err error
|
||||
s.ctrClient, err = ctr.NewCtr("default", 1, &ctr.CtrConfig{}, s.logger)
|
||||
if err != nil {
|
||||
s.logger.Error("初始化 meshray-ctr 失败", zap.Error(err))
|
||||
return fmt.Errorf("初始化 ctr 失败:%w", err)
|
||||
}
|
||||
|
||||
// 需要鉴权的接口
|
||||
protected := v1.Group("")
|
||||
protected.Use(middleware.JWTAuth(s.config.JWT.Secret))
|
||||
{
|
||||
// 初始化 Service 层(注入 ctr 客户端)
|
||||
networkService := service.NewNetworkService(s.store, s.ctrClient, s.logger)
|
||||
deviceService := service.NewDeviceService(s.store, s.ctrClient)
|
||||
userService := service.NewUserService(s.store)
|
||||
policyService := service.NewPolicyService(s.store)
|
||||
settingsService := service.NewSettingsService(s.store, s.logger)
|
||||
pendingJoinService := service.NewPendingJoinService(s.store)
|
||||
|
||||
// 初始化 MeshSeedService(需要 Ed25519 签名密钥)
|
||||
signingKey, err := s.loadSigningKey()
|
||||
if err != nil {
|
||||
s.logger.Error("加载签名密钥失败", zap.Error(err))
|
||||
return fmt.Errorf("加载签名密钥失败:%w", err)
|
||||
}
|
||||
meshSeedService := service.NewMeshSeedService(s.store, s.logger, signingKey, "node-1")
|
||||
|
||||
// 初始化 SystemConfigService(系统配置服务)
|
||||
systemConfigService := service.NewSystemConfigService(s.store, s.logger)
|
||||
|
||||
// 初始化 ExternalService 相关 Handler
|
||||
serviceService := service.NewServiceService(s.store)
|
||||
serviceHandler := handler.NewServiceHandler(serviceService, s.logger)
|
||||
|
||||
// 初始化 SystemConfigHandler(系统配置处理器)
|
||||
systemConfigHandler := handler.NewSystemConfigHandler(systemConfigService, s.logger)
|
||||
|
||||
// 初始化 PendingJoinHandler(待审核处理器)
|
||||
pendingJoinHandler := handler.NewPendingJoinHandler(pendingJoinService, s.store, s.logger)
|
||||
|
||||
// 初始化 handlers(使用 Service 层)
|
||||
networkHandler := handler.NewNetworkHandler(networkService, meshSeedService, s.store, s.logger)
|
||||
deviceHandler := handler.NewDeviceHandler(deviceService, s.logger)
|
||||
adminHandler := handler.NewAdminHandler(userService, s.logger)
|
||||
dashboardHandler := handler.NewDashboardHandler(s.store, s.logger)
|
||||
policyHandler := handler.NewPolicyHandler(policyService, s.logger)
|
||||
settingsHandler := handler.NewSettingsHandler(settingsService, s.logger)
|
||||
wsHandler := handler.NewWSHandler(s.ctrClient, s.store, s.logger)
|
||||
|
||||
ddnsService, err := service.NewDDNSService(s.store.DB())
|
||||
if err != nil {
|
||||
s.logger.Error("初始化 DDNSService 失败", zap.Error(err))
|
||||
return fmt.Errorf("初始化 DDNSService 失败:%w", err)
|
||||
}
|
||||
// 启动后台自动同步协程
|
||||
go ddnsService.StartAutoSync(context.Background())
|
||||
|
||||
ddnsHandler := handler.NewDDNSHandler(ddnsService)
|
||||
|
||||
// ✅ 新增:DDNS Usage Handler
|
||||
ddnsUsageHandler := handler.NewDDNSUsageHandler(s.store.DB(), s.logger)
|
||||
|
||||
// ✅ 新增:IP 检测 API
|
||||
ddnsDetectHandler := handler.NewIPDetectHandler()
|
||||
|
||||
// 管理员管理
|
||||
protected.GET("/admin/profile", adminHandler.GetProfile)
|
||||
protected.PUT("/admin/profile", adminHandler.UpdateProfile)
|
||||
protected.POST("/admin/change-password", adminHandler.ChangePassword)
|
||||
|
||||
// ✅ 系统备份恢复
|
||||
backupHandler := corehandler.NewBackupHandler(s.store.DB(), s.logger)
|
||||
protected.POST("/system/backup", backupHandler.CreateBackup)
|
||||
protected.GET("/system/backups", backupHandler.ListBackups)
|
||||
protected.POST("/system/restore", backupHandler.RestoreBackup)
|
||||
protected.DELETE("/system/backup", backupHandler.DeleteBackup)
|
||||
protected.GET("/system/backup/download", backupHandler.DownloadBackup)
|
||||
|
||||
// ✅ 系统更新检查
|
||||
updateHandler := corehandler.NewUpdateHandler(s.config.App.Version)
|
||||
protected.GET("/system/update/check", func(c *gin.Context) {
|
||||
resp, err := updateHandler.CheckUpdate()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "检查更新失败",
|
||||
})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": resp,
|
||||
})
|
||||
})
|
||||
|
||||
// ✅ WebSocket 实时通知推送
|
||||
notificationSvc := service.NewNotificationService(s.store.DB(), s.logger)
|
||||
notificationHandler := corehandler.NewNotificationHandler(notificationSvc, s.logger, s.store.DB())
|
||||
protected.GET("/notifications", notificationHandler.GetNotifications)
|
||||
protected.GET("/notifications/unread-count", notificationHandler.GetUnreadCount)
|
||||
protected.POST("/notifications/:id/read", notificationHandler.MarkAsRead)
|
||||
protected.POST("/notifications/read-all", notificationHandler.MarkAllAsRead)
|
||||
protected.DELETE("/notifications/:id", notificationHandler.DeleteNotification)
|
||||
protected.POST("/notifications/test", notificationHandler.TestSendNotification)
|
||||
|
||||
// DDNS 配置
|
||||
protected.GET("/ddns/config", ddnsHandler.GetDDNSConfig)
|
||||
protected.PUT("/ddns/config", ddnsHandler.UpdateDDNSConfig)
|
||||
protected.POST("/ddns/test", ddnsHandler.TestDDNSConnectivity)
|
||||
protected.POST("/ddns/sync", ddnsHandler.SyncDDNS)
|
||||
|
||||
// ✅ IP 检测 API(用于前端自动填充)
|
||||
protected.GET("/ddns/detect-ip", ddnsDetectHandler.DetectIP)
|
||||
|
||||
// ✅ DDNS 统计 API(用于 Dashboard 监控)
|
||||
ddnsStatsHandler := handler.NewDDNSStatsHandler(s.store.DB())
|
||||
protected.GET("/ddns/stats", ddnsStatsHandler.GetDDNSStats)
|
||||
|
||||
// ✅ DDNS Usage 管理
|
||||
protected.POST("/ddns/usages", ddnsUsageHandler.CreateUsage)
|
||||
protected.GET("/ddns/usages/available", ddnsUsageHandler.GetAvailableUsages)
|
||||
protected.GET("/ddns/check-prefix", ddnsUsageHandler.CheckPrefixOccupied)
|
||||
|
||||
// Dashboard
|
||||
protected.GET("/dashboard/stats", dashboardHandler.GetStats)
|
||||
protected.GET("/dashboard/logs", dashboardHandler.GetRecentLogs)
|
||||
protected.GET("/dashboard/system-info", dashboardHandler.GetSystemInfo)
|
||||
protected.GET("/dashboard/link-distribution", dashboardHandler.GetLinkDistribution)
|
||||
protected.POST("/dashboard/logs/clear", dashboardHandler.ClearLogs) // 新增:清理日志
|
||||
|
||||
// 系统设置
|
||||
protected.GET("/settings", settingsHandler.GetSettings)
|
||||
protected.PUT("/settings", settingsHandler.UpdateSettings)
|
||||
|
||||
// 系统配置(WG 模式等)
|
||||
protected.GET("/system-config/wg-mode", systemConfigHandler.GetWGMode)
|
||||
protected.PUT("/system-config/wg-mode", systemConfigHandler.SetWGMode)
|
||||
|
||||
// 系统运维(P1/P2 功能)- 使用 SystemConfigHandler 统一管理
|
||||
protected.POST("/system/change-password", systemConfigHandler.ChangePassword) // P1: 修改密码
|
||||
protected.POST("/system/restart-core", systemConfigHandler.RestartCore) // P1: 重启核心
|
||||
|
||||
// 组网管理
|
||||
protected.GET("/networks", networkHandler.ListNetworks)
|
||||
protected.POST("/networks", networkHandler.CreateNetwork)
|
||||
protected.POST("/networks/preview", networkHandler.PreviewMeshSeed)
|
||||
protected.POST("/networks/join", networkHandler.JoinNetwork)
|
||||
protected.GET("/networks/:id", networkHandler.GetNetwork)
|
||||
protected.PUT("/networks/:id", networkHandler.UpdateNetwork)
|
||||
protected.DELETE("/networks/:id", networkHandler.DeleteNetwork)
|
||||
protected.POST("/networks/:id/start", networkHandler.StartNetwork)
|
||||
protected.POST("/networks/:id/stop", networkHandler.StopNetwork)
|
||||
protected.POST("/networks/:id/switch-mode", networkHandler.SwitchMode)
|
||||
protected.POST("/networks/:id/meshseed", networkHandler.GenerateMeshSeed)
|
||||
|
||||
// 设备管理(使用独立前缀避免路由冲突)
|
||||
protected.GET("/devices", deviceHandler.ListDevices)
|
||||
protected.POST("/devices", deviceHandler.CreateDevice)
|
||||
protected.GET("/devices/:id", deviceHandler.GetDevice)
|
||||
protected.PUT("/devices/:id", deviceHandler.UpdateDevice)
|
||||
protected.DELETE("/devices/:id", deviceHandler.DeleteDevice)
|
||||
protected.GET("/devices/:id/config", deviceHandler.GenerateDeviceConfig)
|
||||
|
||||
// 策略管理
|
||||
protected.GET("/policies", policyHandler.ListPolicies)
|
||||
protected.POST("/policies", policyHandler.CreatePolicy)
|
||||
protected.GET("/policies/:id", policyHandler.GetPolicy)
|
||||
protected.PUT("/policies/:id", policyHandler.UpdatePolicy)
|
||||
protected.DELETE("/policies/:id", policyHandler.DeletePolicy)
|
||||
|
||||
// ExternalService 管理(用户视角)
|
||||
protected.GET("/services", serviceHandler.ListServices)
|
||||
protected.POST("/services", serviceHandler.CreateService)
|
||||
protected.GET("/services/:id", serviceHandler.GetService)
|
||||
protected.PUT("/services/:id", serviceHandler.UpdateService)
|
||||
protected.DELETE("/services/:id", serviceHandler.DeleteService)
|
||||
protected.POST("/services/:id/test", serviceHandler.TestServiceConnectivity)
|
||||
protected.GET("/services/schema", serviceHandler.GetServiceSchema)
|
||||
|
||||
protected.GET("/monitor/metrics", s.handleMetrics)
|
||||
|
||||
// 注册 WebSocket 通道
|
||||
protected.GET("/ws", wsHandler.ServeWS)
|
||||
|
||||
// 待审核管理
|
||||
protected.GET("/pending-joins", pendingJoinHandler.ListPendingJoins)
|
||||
protected.POST("/pending-joins/:id/approve", pendingJoinHandler.ApproveJoin)
|
||||
protected.POST("/pending-joins/:id/reject", pendingJoinHandler.RejectJoin)
|
||||
protected.GET("/pending-joins/count", pendingJoinHandler.CountPending)
|
||||
protected.POST("/pending-joins/cleanup", pendingJoinHandler.DeleteExpired)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleMetrics 监控指标 API
|
||||
func (s *Server) handleMetrics(c *gin.Context) {
|
||||
// 获取系统信息
|
||||
var memStats runtime.MemStats
|
||||
runtime.ReadMemStats(&memStats)
|
||||
|
||||
// 获取 CPU 使用率(简化版本)
|
||||
cpuPercent := 0.0
|
||||
if cpus, err := cpu.Percent(time.Second, false); err == nil && len(cpus) > 0 {
|
||||
cpuPercent = cpus[0]
|
||||
}
|
||||
|
||||
// 网络统计(从数据库)
|
||||
var deviceCount, networkCount, onlineCount int64
|
||||
s.store.DB().Model(&model.Device{}).Count(&deviceCount)
|
||||
s.store.DB().Model(&model.Network{}).Count(&networkCount)
|
||||
s.store.DB().Model(&model.Device{}).Where("status = ?", "online").Count(&onlineCount)
|
||||
|
||||
// 返回 Prometheus 格式或 JSON 格式
|
||||
accept := c.GetHeader("Accept")
|
||||
if strings.Contains(accept, "text/plain") {
|
||||
// Prometheus 格式
|
||||
metrics := fmt.Sprintf(`# HELP meshray_memory_alloc_bytes 当前内存使用量
|
||||
# TYPE meshray_memory_alloc_bytes gauge
|
||||
meshray_memory_alloc_bytes %d
|
||||
|
||||
# HELP meshray_cpu_usage_percent CPU 使用率
|
||||
# TYPE meshray_cpu_usage_percent gauge
|
||||
meshray_cpu_usage_percent %.2f
|
||||
|
||||
# HELP meshray_device_total 设备总数
|
||||
# TYPE meshray_device_total gauge
|
||||
meshray_device_total %d
|
||||
|
||||
# HELP meshray_device_online 在线设备数
|
||||
# TYPE meshray_device_online gauge
|
||||
meshray_device_online %d
|
||||
|
||||
# HELP meshray_network_total 网络总数
|
||||
# TYPE meshray_network_total gauge
|
||||
meshray_network_total %d
|
||||
`,
|
||||
memStats.Alloc,
|
||||
cpuPercent,
|
||||
deviceCount,
|
||||
onlineCount,
|
||||
networkCount)
|
||||
|
||||
c.Header("Content-Type", "text/plain; version=0.0.4")
|
||||
c.String(200, metrics)
|
||||
} else {
|
||||
// JSON 格式(前端使用)
|
||||
c.JSON(200, gin.H{
|
||||
"data": gin.H{
|
||||
"memory": gin.H{
|
||||
"alloc_bytes": memStats.Alloc,
|
||||
"alloc_mb": float64(memStats.Alloc) / 1024 / 1024,
|
||||
"sys_bytes": memStats.Sys,
|
||||
"num_gc": memStats.NumGC,
|
||||
},
|
||||
"cpu": gin.H{
|
||||
"usage_percent": cpuPercent,
|
||||
},
|
||||
"devices": gin.H{
|
||||
"total": deviceCount,
|
||||
"online": onlineCount,
|
||||
"offline": deviceCount - onlineCount,
|
||||
},
|
||||
"networks": gin.H{
|
||||
"total": networkCount,
|
||||
},
|
||||
"timestamp": time.Now().Unix(),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// getContentType 根据文件扩展名返回 Content-Type
|
||||
func getContentType(filePath string) string {
|
||||
ext := strings.ToLower(filepath.Ext(filePath))
|
||||
switch ext {
|
||||
case ".html":
|
||||
return "text/html; charset=utf-8"
|
||||
case ".css":
|
||||
return "text/css; charset=utf-8"
|
||||
case ".js":
|
||||
return "application/javascript; charset=utf-8"
|
||||
case ".json":
|
||||
return "application/json; charset=utf-8"
|
||||
case ".png":
|
||||
return "image/png"
|
||||
case ".jpg", ".jpeg":
|
||||
return "image/jpeg"
|
||||
case ".gif":
|
||||
return "image/gif"
|
||||
case ".svg":
|
||||
return "image/svg+xml"
|
||||
case ".ico":
|
||||
return "image/x-icon"
|
||||
default:
|
||||
return "application/octet-stream"
|
||||
}
|
||||
}
|
||||
|
||||
// Run 启动 API 服务器
|
||||
func (s *Server) Run() error {
|
||||
addr := fmt.Sprintf(":%d", s.config.Server.Port)
|
||||
s.logger.Info("Starting MeshRay", zap.String("address", addr))
|
||||
|
||||
// 打印访问地址
|
||||
fmt.Println("")
|
||||
fmt.Printf("🌐 MeshRay 启动成功!\n")
|
||||
fmt.Printf("📍 访问地址:http://localhost:%d\n", s.config.Server.Port)
|
||||
fmt.Printf("💡 提示:请在浏览器中打开上述地址访问管理面板\n")
|
||||
fmt.Println("")
|
||||
|
||||
return s.engine.Run(addr)
|
||||
}
|
||||
|
||||
// loadSigningKey 加载或生成 Ed25519 签名密钥
|
||||
func (s *Server) loadSigningKey() (ed25519.PrivateKey, error) {
|
||||
var key model.SecurityKey
|
||||
err := s.store.DB().Where("name = ?", "meshseed_signing").First(&key).Error
|
||||
|
||||
if err == nil {
|
||||
// 从数据库加载已有密钥
|
||||
keyBytes, decodeErr := base64.StdEncoding.DecodeString(key.Value)
|
||||
if decodeErr != nil {
|
||||
return nil, fmt.Errorf("解码密钥失败:%w", decodeErr)
|
||||
}
|
||||
return ed25519.PrivateKey(keyBytes), nil
|
||||
}
|
||||
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, fmt.Errorf("查询密钥失败:%w", err)
|
||||
}
|
||||
|
||||
// 密钥不存在,生成新密钥并保存
|
||||
_, newKey, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("生成密钥失败:%w", err)
|
||||
}
|
||||
|
||||
keyBytes := []byte(newKey)
|
||||
err = s.store.DB().Create(&model.SecurityKey{
|
||||
Name: "meshseed_signing",
|
||||
Value: base64.StdEncoding.EncodeToString(keyBytes),
|
||||
Algorithm: "ed25519",
|
||||
Purpose: "MeshSeed 数字签名",
|
||||
}).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("保存密钥失败:%w", err)
|
||||
}
|
||||
|
||||
s.logger.Info("已生成新的签名密钥")
|
||||
return newKey, nil
|
||||
}
|
||||
Reference in New Issue
Block a user