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
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
// Config 全局配置结构
|
||||
type Config struct {
|
||||
App AppConfig `mapstructure:"app"`
|
||||
Server ServerConfig `mapstructure:"server"`
|
||||
Database DatabaseConfig `mapstructure:"database"`
|
||||
JWT JWTConfig `mapstructure:"jwt"`
|
||||
Log LogConfig `mapstructure:"log"`
|
||||
Encryption EncryptionConfig `mapstructure:"encryption"`
|
||||
WireGuard WireGuardConfig `mapstructure:"wireguard"`
|
||||
STUN STUNConfig `mapstructure:"stun"`
|
||||
TURN TURNConfig `mapstructure:"turn"`
|
||||
}
|
||||
|
||||
// AppConfig 应用配置
|
||||
type AppConfig struct {
|
||||
Version string `mapstructure:"version"` // 版本号
|
||||
BuildDate string `mapstructure:"build_date"` // 构建日期
|
||||
}
|
||||
|
||||
// ServerConfig 服务器配置
|
||||
type ServerConfig struct {
|
||||
Port int `mapstructure:"port"`
|
||||
Mode string `mapstructure:"mode"` // debug, release, test
|
||||
StaticPath string `mapstructure:"static_path"` // 前端静态文件目录
|
||||
}
|
||||
|
||||
// DatabaseConfig 数据库配置
|
||||
type DatabaseConfig struct {
|
||||
Type string `mapstructure:"type"`
|
||||
Path string `mapstructure:"path"`
|
||||
}
|
||||
|
||||
// JWTConfig JWT 配置
|
||||
type JWTConfig struct {
|
||||
Secret string `mapstructure:"secret"`
|
||||
AccessTokenDur string `mapstructure:"access_token_duration"`
|
||||
RefreshTokenDur string `mapstructure:"refresh_token_duration"`
|
||||
}
|
||||
|
||||
// LogConfig 日志配置
|
||||
type LogConfig struct {
|
||||
Level string `mapstructure:"level"`
|
||||
Format string `mapstructure:"format"`
|
||||
Output string `mapstructure:"output"`
|
||||
MaxSize int `mapstructure:"max_size"`
|
||||
MaxBackups int `mapstructure:"max_backups"`
|
||||
MaxAge int `mapstructure:"max_age"`
|
||||
}
|
||||
|
||||
// EncryptionConfig 加密配置
|
||||
type EncryptionConfig struct {
|
||||
NetworkSecretKey string `mapstructure:"network_secret_key"`
|
||||
}
|
||||
|
||||
// WireGuardConfig WireGuard 配置(固定使用用户态)
|
||||
type WireGuardConfig struct {
|
||||
// 已删除 PreferredMode 字段 - 统一使用 wireguard-go 用户态
|
||||
}
|
||||
|
||||
// STUNConfig STUN 配置
|
||||
type STUNConfig struct {
|
||||
DefaultServers []string `mapstructure:"default_servers"` // 默认 STUN 服务器列表
|
||||
SelectionStrategy string `mapstructure:"selection_strategy"` // 选择策略:auto, domestic, international, custom
|
||||
AutoTest bool `mapstructure:"auto_test"` // 是否启用自动测试
|
||||
TestInterval int `mapstructure:"test_interval"` // 测试间隔(秒)
|
||||
Timeout int `mapstructure:"timeout"` // 超时时间(秒)
|
||||
}
|
||||
|
||||
// TURNConfig TURN 配置
|
||||
type TURNConfig struct {
|
||||
DefaultServers []TURNServerConfig `mapstructure:"default_servers"` // 默认 TURN 服务器列表
|
||||
}
|
||||
|
||||
// TURNServerConfig TURN 服务器配置
|
||||
type TURNServerConfig struct {
|
||||
URL string `mapstructure:"url"` // TURN 服务器 URL
|
||||
Username string `mapstructure:"username"` // 用户名
|
||||
Credential string `mapstructure:"credential"` // 凭证
|
||||
AuthType string `mapstructure:"auth_type"` // 鉴权方式:credential, token, secret
|
||||
}
|
||||
|
||||
// Load 加载配置文件
|
||||
func Load(configPath string) (*Config, error) {
|
||||
// 如果未指定配置文件路径,使用默认路径
|
||||
if configPath == "" {
|
||||
configPath = "config.yaml"
|
||||
}
|
||||
|
||||
// 检查配置文件是否存在
|
||||
if _, err := os.Stat(configPath); os.IsNotExist(err) {
|
||||
// 尝试从示例文件复制
|
||||
examplePath := "configs/config.example.yaml"
|
||||
if _, err := os.Stat(examplePath); err == nil {
|
||||
// 创建目录
|
||||
dir := filepath.Dir(configPath)
|
||||
if dir != "." {
|
||||
os.MkdirAll(dir, 0755)
|
||||
}
|
||||
// 复制示例配置
|
||||
data, _ := os.ReadFile(examplePath)
|
||||
os.WriteFile(configPath, data, 0644)
|
||||
}
|
||||
}
|
||||
|
||||
// 读取配置文件
|
||||
viper.SetConfigFile(configPath)
|
||||
viper.AutomaticEnv()
|
||||
|
||||
if err := viper.ReadInConfig(); err != nil {
|
||||
return nil, fmt.Errorf("读取配置文件失败:%w", err)
|
||||
}
|
||||
|
||||
var config Config
|
||||
if err := viper.Unmarshal(&config); err != nil {
|
||||
return nil, fmt.Errorf("解析配置文件失败:%w", err)
|
||||
}
|
||||
|
||||
// 自动填充默认值
|
||||
if config.Server.Port == 0 {
|
||||
config.Server.Port = 9531
|
||||
}
|
||||
if config.Server.Mode == "" {
|
||||
config.Server.Mode = "release"
|
||||
}
|
||||
|
||||
// 自动生成 JWT Secret
|
||||
if config.JWT.Secret == "" {
|
||||
config.JWT.Secret = generateJWTSecret()
|
||||
}
|
||||
|
||||
// 自动生成加密密钥
|
||||
if config.Encryption.NetworkSecretKey == "" {
|
||||
config.Encryption.NetworkSecretKey = generateEncryptionKey()
|
||||
}
|
||||
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
// generateJWTSecret 使用 crypto/rand 生成随机 JWT Secret
|
||||
func generateJWTSecret() string {
|
||||
b := make([]byte, 32)
|
||||
_, err := rand.Read(b)
|
||||
if err != nil {
|
||||
// 极端情况回退(几乎不会发生)
|
||||
return uuid.New().String()
|
||||
}
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// generateEncryptionKey 使用 crypto/rand 生成加密密钥
|
||||
func generateEncryptionKey() string {
|
||||
key := make([]byte, 32)
|
||||
_, err := rand.Read(key)
|
||||
if err != nil {
|
||||
// 极端情况回退(几乎不会发生)
|
||||
return uuid.New().String() + uuid.New().String()
|
||||
}
|
||||
return hex.EncodeToString(key)
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
package ctr
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"sync"
|
||||
|
||||
"git.zkcoi.com/zkcoi/meshray/core"
|
||||
"git.zkcoi.com/zkcoi/meshray/core/connect"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Ctr meshray-ctr 调度中心
|
||||
type Ctr struct {
|
||||
name string // 组网名称(用于日志)
|
||||
networkID uint64 // 组网 ID(雪花算法 ID)
|
||||
config *CtrConfig
|
||||
logger *zap.Logger
|
||||
|
||||
// WireGuard 管理器
|
||||
wgManager *WGManager
|
||||
|
||||
// Core 实例(直接集成,无需 gRPC)
|
||||
coreInst *core.Core
|
||||
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// CtrConfig 配置
|
||||
type CtrConfig struct {
|
||||
// 空配置,保留结构体以备未来扩展
|
||||
}
|
||||
|
||||
// NewCtr 创建调度中心
|
||||
func NewCtr(name string, networkID uint64, config *CtrConfig, logger *zap.Logger) (*Ctr, error) {
|
||||
ctr := &Ctr{
|
||||
name: name,
|
||||
networkID: networkID,
|
||||
config: config,
|
||||
logger: logger,
|
||||
}
|
||||
|
||||
// 初始化 WireGuard 管理器
|
||||
ctr.wgManager = NewWGManager(logger)
|
||||
|
||||
// 初始化 Core 实例(直接集成,无需 gRPC)
|
||||
ctr.coreInst = core.NewCore(logger)
|
||||
|
||||
return ctr, nil
|
||||
}
|
||||
|
||||
// Start 启动调度中心
|
||||
func (c *Ctr) Start() error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
c.logger.Info("启动 meshray-ctr",
|
||||
zap.String("name", c.name),
|
||||
zap.Uint64("network_id", c.networkID))
|
||||
|
||||
// ✅ Watchdog 监控暂不实现(P3 阶段)
|
||||
// go c.watchdog.Start()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop 停止调度中心
|
||||
func (c *Ctr) Stop() error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
c.logger.Info("停止 meshray-ctr",
|
||||
zap.Uint64("network_id", c.networkID))
|
||||
|
||||
// 停止 Core 实例(直接调用方法)
|
||||
if c.coreInst != nil {
|
||||
// ✅ Close 方法已实现(会停止所有 Engine)
|
||||
c.coreInst.Close()
|
||||
}
|
||||
|
||||
// 停止 WireGuard 管理
|
||||
if err := c.wgManager.Stop(); err != nil {
|
||||
c.logger.Error("停止 WireGuard 管理失败", zap.Error(err))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateNetwork 创建网络(根据组网模式决定行为)
|
||||
// - 原生模式:仅创建 WG 设备
|
||||
// - 增强模式:创建 WG 设备 + 启动 Core 实例
|
||||
func (c *Ctr) CreateNetwork(networkID uint64, subnet string, listenPort int, meshMode string) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
c.logger.Info("开始创建网络",
|
||||
zap.Uint64("network_id", networkID),
|
||||
zap.String("subnet", subnet),
|
||||
zap.Int("listen_port", listenPort),
|
||||
zap.String("mesh_mode", meshMode))
|
||||
|
||||
// 1. 创建 WireGuard 设备(两种模式都需要)
|
||||
networkIDStr := strconv.FormatUint(networkID, 10)
|
||||
if err := c.wgManager.CreateDevice(networkIDStr, subnet, listenPort); err != nil {
|
||||
return fmt.Errorf("创建 WireGuard 设备失败:%w", err)
|
||||
}
|
||||
c.logger.Debug("WireGuard 设备创建成功",
|
||||
zap.Uint64("network_id", networkID))
|
||||
|
||||
// 2. 仅增强模式需要创建并启动 Core 实例
|
||||
if meshMode == "enhanced" {
|
||||
// 直接调用 Core 实例的方法(无需 gRPC)
|
||||
metrics := core.NewMetrics()
|
||||
engine, err := c.coreInst.CreateEngine(networkIDStr, metrics)
|
||||
if err != nil {
|
||||
c.wgManager.DeleteDevice(networkIDStr) // 回滚 WG设备
|
||||
c.logger.Error("创建 Engine 失败",
|
||||
zap.Uint64("network_id", networkID),
|
||||
zap.Error(err))
|
||||
return fmt.Errorf("创建 Engine 失败:%w", err)
|
||||
}
|
||||
|
||||
if err := engine.Start(); err != nil {
|
||||
// ✅ 调用 Stop 方法停止(会清理所有资源)
|
||||
engine.Stop()
|
||||
c.wgManager.DeleteDevice(networkIDStr) // 回滚 WG 设备
|
||||
c.logger.Error("启动 Engine 失败",
|
||||
zap.Uint64("network_id", networkID),
|
||||
zap.Error(err))
|
||||
return fmt.Errorf("启动 Engine 失败:%w", err)
|
||||
}
|
||||
|
||||
c.logger.Info("增强模式:Engine 实例已启动",
|
||||
zap.Uint64("network_id", networkID))
|
||||
} else {
|
||||
c.logger.Info("原生模式:仅创建 WG 设备,不启动 Core",
|
||||
zap.Uint64("network_id", networkID))
|
||||
}
|
||||
|
||||
c.logger.Info("网络创建成功",
|
||||
zap.Uint64("network_id", networkID),
|
||||
zap.String("mesh_mode", meshMode))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteNetwork 删除网络
|
||||
func (c *Ctr) DeleteNetwork(networkID uint64) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
c.logger.Info("开始删除网络",
|
||||
zap.Uint64("network_id", networkID))
|
||||
|
||||
// 1. Core 实例不需要显式删除(它是内存中的对象)
|
||||
// 如果需要清理,可以在未来实现 Engine.Stop() 方法
|
||||
networkIDStr := strconv.FormatUint(networkID, 10)
|
||||
|
||||
// 2. 删除 WireGuard 设备
|
||||
if err := c.wgManager.DeleteDevice(networkIDStr); err != nil {
|
||||
return fmt.Errorf("删除 WireGuard 设备失败:%w", err)
|
||||
}
|
||||
|
||||
c.logger.Info("网络删除成功",
|
||||
zap.Uint64("network_id", networkID))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddPeer 添加 Peer
|
||||
func (c *Ctr) AddPeer(networkID uint64, publicKey, allowedIP string) error {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
c.logger.Info("添加 Peer",
|
||||
zap.Uint64("network_id", networkID),
|
||||
zap.String("public_key", publicKey[:8]+"..."))
|
||||
|
||||
// 1. 添加到 WireGuard
|
||||
networkIDStr := strconv.FormatUint(networkID, 10)
|
||||
if err := c.wgManager.AddPeer(networkIDStr, publicKey, allowedIP); err != nil {
|
||||
return fmt.Errorf("添加 Peer 失败:%w", err)
|
||||
}
|
||||
|
||||
// 2. 通知 Core
|
||||
// 如果已经在 Enhanced 模式,需要接管刚刚加入的这个 Peer
|
||||
engine, err := c.coreInst.GetEngine(networkIDStr)
|
||||
if err == nil && engine != nil {
|
||||
// 引擎存在,说明是 enhanced 模式
|
||||
// 由 Core 分配一个本地端口
|
||||
localPort, err := engine.Bind(publicKey, 0)
|
||||
if err != nil {
|
||||
c.logger.Error("增强模式动态 AddPeer Bind 失败", zap.Error(err))
|
||||
} else {
|
||||
// 通知 WG 更新为本地代理 Endpoint
|
||||
newEndpoint := fmt.Sprintf("127.0.0.1:%d", localPort)
|
||||
if err := c.wgManager.UpdatePeerEndpoint(networkIDStr, publicKey, newEndpoint); err != nil {
|
||||
c.logger.Warn("增强模式自动重写 Endpoint 失败", zap.Error(err))
|
||||
engine.Unbind(publicKey)
|
||||
} else {
|
||||
c.logger.Info("增强模式:动态 Peer 添加并成功代理给 Core",
|
||||
zap.String("public_key", publicKey[:8]+"..."),
|
||||
zap.String("new_endpoint", newEndpoint))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
c.logger.Debug("Core 未启动,普通模式无需代理 Peer", zap.Uint64("network_id", networkID))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemovePeer 移除 Peer
|
||||
func (c *Ctr) RemovePeer(networkID uint64, publicKey string) error {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
c.logger.Info("移除 Peer",
|
||||
zap.Uint64("network_id", networkID),
|
||||
zap.String("public_key", publicKey[:8]+"..."))
|
||||
|
||||
// 从 WireGuard 移除
|
||||
networkIDStr := strconv.FormatUint(networkID, 10)
|
||||
if err := c.wgManager.RemovePeer(networkIDStr, publicKey); err != nil {
|
||||
return fmt.Errorf("移除 Peer 失败:%w", err)
|
||||
}
|
||||
|
||||
// 通知 Core 卸载这台 Peer 的监听
|
||||
engine, err := c.coreInst.GetEngine(networkIDStr)
|
||||
if err == nil && engine != nil {
|
||||
engine.Unbind(publicKey)
|
||||
c.logger.Info("增强模式:Core 已解除对该 Peer 的挂载", zap.String("public_key", publicKey[:8]+"..."))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetSTUNTURNConfig 为指定网络设置 STUN/TURN 配置
|
||||
func (c *Ctr) SetSTUNTURNConfig(networkID uint64, stunServers []string, turnServers []connect.TURNServerConfig) error {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
networkIDStr := strconv.FormatUint(networkID, 10)
|
||||
|
||||
// 获取 Engine 实例
|
||||
engine, err := c.coreInst.GetEngine(networkIDStr)
|
||||
if err != nil {
|
||||
c.logger.Debug("网络未启动增强模式,跳过 STUN/TURN 配置",
|
||||
zap.Uint64("network_id", networkID))
|
||||
return nil // 无需错误,因为原生模式不需要
|
||||
}
|
||||
|
||||
// 更新 WebRTC 工厂的 ICE 配置
|
||||
engine.SetICEConfig(connect.ICEConfig{
|
||||
STUNServers: stunServers,
|
||||
TURNServers: turnServers,
|
||||
})
|
||||
|
||||
c.logger.Info("STUN/TURN 配置已设置",
|
||||
zap.Uint64("network_id", networkID),
|
||||
zap.Int("stun_count", len(stunServers)),
|
||||
zap.Int("turn_count", len(turnServers)))
|
||||
|
||||
return nil
|
||||
}
|
||||
func (c *Ctr) GetStatus(networkID uint64) (*NetworkStatus, error) {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
networkIDStr := strconv.FormatUint(networkID, 10)
|
||||
status := &NetworkStatus{
|
||||
NetworkID: networkIDStr,
|
||||
}
|
||||
|
||||
// 获取 WireGuard 状态
|
||||
wgStatus, err := c.wgManager.GetStatus(networkIDStr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
status.WGStatus = wgStatus
|
||||
|
||||
// 获取 Core 状态(直接调用 Engine 的方法)
|
||||
engine, err := c.coreInst.GetEngine(networkIDStr)
|
||||
if err == nil && engine != nil {
|
||||
coreStatus, err := engine.GetStatus()
|
||||
if err == nil {
|
||||
status.CoreStatus = coreStatus
|
||||
}
|
||||
}
|
||||
|
||||
return status, nil
|
||||
}
|
||||
|
||||
// SwitchMode 切换传输模式(原生→增强)
|
||||
// 仅支持 "native" → "enhanced" 单向切换
|
||||
func (c *Ctr) SwitchMode(networkID uint64, mode string) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
c.logger.Info("开始切换传输模式",
|
||||
zap.Uint64("network_id", networkID),
|
||||
zap.String("target_mode", mode))
|
||||
|
||||
// 1. 验证目标模式
|
||||
if mode != "enhanced" {
|
||||
return fmt.Errorf("仅支持切换到 enhanced 模式,当前请求:%s", mode)
|
||||
}
|
||||
|
||||
networkIDStr := strconv.FormatUint(networkID, 10)
|
||||
|
||||
// 2. 获取当前 WG 状态(获取 Peer 数量)
|
||||
wgStatus, err := c.wgManager.GetStatus(networkIDStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取 WG 状态失败:%w", err)
|
||||
}
|
||||
|
||||
c.logger.Info("获取到 WG 状态",
|
||||
zap.Int("peer_count", wgStatus.PeerCount))
|
||||
|
||||
// 3. 创建并启动 Core Engine(增强模式核心)
|
||||
metrics := core.NewMetrics()
|
||||
engine, err := c.coreInst.CreateEngine(networkIDStr, metrics)
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建 Core Engine 失败:%w", err)
|
||||
}
|
||||
|
||||
if err := engine.Start(); err != nil {
|
||||
return fmt.Errorf("启动 Core Engine 失败:%w", err)
|
||||
}
|
||||
|
||||
c.logger.Info("Core Engine 已启动",
|
||||
zap.Uint64("network_id", networkID))
|
||||
|
||||
// 4. 获取 Peer 列表
|
||||
peers, err := c.wgManager.ListPeers(networkIDStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取 Peer 列表失败:%w", err)
|
||||
}
|
||||
|
||||
// 5. 修改 Peer Endpoint → 127.0.0.1:{port},并且让 Core 绑定
|
||||
// 增强模式下,所有 Peer 的 Endpoint 都指向本地回环地址
|
||||
for _, peer := range peers {
|
||||
// 1. Core 引擎动态接管,传 0 意为让操作系统挑个未占用的随意回环端口
|
||||
localPort, err := engine.Bind(peer.PublicKey, 0)
|
||||
if err != nil {
|
||||
c.logger.Error("Engine Bind 失败",
|
||||
zap.String("public_key", peer.PublicKey[:8]+"..."),
|
||||
zap.Error(err))
|
||||
continue
|
||||
}
|
||||
|
||||
newEndpoint := fmt.Sprintf("127.0.0.1:%d", localPort)
|
||||
|
||||
// 2. 将此端口告知 WG 更新 Endpoint
|
||||
if err := c.wgManager.UpdatePeerEndpoint(networkIDStr, peer.PublicKey, newEndpoint); err != nil {
|
||||
c.logger.Warn("更新 Peer Endpoint 失败",
|
||||
zap.String("public_key", peer.PublicKey[:8]+"..."),
|
||||
zap.Error(err))
|
||||
|
||||
// 如果更新失败,解绑 Core
|
||||
engine.Unbind(peer.PublicKey)
|
||||
continue
|
||||
}
|
||||
|
||||
c.logger.Info("Peer Endpoint 已成功切换并接入 Core",
|
||||
zap.String("public_key", peer.PublicKey[:8]+"..."),
|
||||
zap.String("original_endpoint", peer.Endpoint),
|
||||
zap.String("new_endpoint", newEndpoint))
|
||||
}
|
||||
|
||||
c.logger.Info("传输模式切换成功(基础功能已实现,P3 阶段待完善)",
|
||||
zap.Uint64("network_id", networkID),
|
||||
zap.String("mode", mode))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateCoreConfig 更新 Core 配置(策略修改后同步)
|
||||
// ✅ P3-1 阶段实现
|
||||
func (c *Ctr) UpdateCoreConfig(networkID uint64, config interface{}) error {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
c.logger.Info("更新 Core 配置",
|
||||
zap.Uint64("network_id", networkID))
|
||||
|
||||
// ✅ P3-1 阶段 - 暂不实现
|
||||
// 未来实现:
|
||||
// 1. 根据策略类型解析配置
|
||||
// 2. 调用 Core 的 UpdateConfig 方法
|
||||
// 3. 持久化到数据库
|
||||
|
||||
return fmt.Errorf("UpdateCoreConfig 尚未实现(P3-1 阶段)")
|
||||
}
|
||||
|
||||
// GetWGMode 获取系统 WG 模式
|
||||
func (c *Ctr) GetWGMode() string {
|
||||
return c.wgManager.GetWGMode()
|
||||
}
|
||||
|
||||
// NetworkStatus 网络状态
|
||||
type NetworkStatus struct {
|
||||
NetworkID string `json:"network_id"`
|
||||
WGStatus *WGStatus `json:"wg_status"`
|
||||
CoreStatus *core.EngineStatus `json:"core_status,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package ctr
|
||||
|
||||
// Client Ctr 客户端接口(Service 层调用)
|
||||
//
|
||||
// 职责:
|
||||
// 1. 定义 Service 层可调用的所有方法
|
||||
// 2. 解耦 Service 和 Ctr 实现
|
||||
// 3. 便于单元测试 Mock
|
||||
//
|
||||
// 使用示例:
|
||||
//
|
||||
// type NetworkService struct {
|
||||
// ctrClient ctr.Client
|
||||
// }
|
||||
//
|
||||
// func (s *NetworkService) CreateNetwork(req *model.Network) error {
|
||||
// return s.ctrClient.CreateNetwork(req.ID, req.Subnet, listenPort)
|
||||
// }
|
||||
type Client interface {
|
||||
// ========== 网络生命周期管理 ==========
|
||||
|
||||
// CreateNetwork 创建网络
|
||||
// networkID: 组网 ID(雪花算法 uint64)
|
||||
// subnet: 子网段(例如:"10.0.0.0/24")
|
||||
// listenPort: WireGuard 监听端口(例如:51820 + hash(networkID))
|
||||
// meshMode: 组网模式("native" | "enhanced")
|
||||
CreateNetwork(networkID uint64, subnet string, listenPort int, meshMode string) error
|
||||
|
||||
// DeleteNetwork 删除网络
|
||||
DeleteNetwork(networkID uint64) error
|
||||
|
||||
// ========== Peer 管理 ==========
|
||||
|
||||
// AddPeer 添加 Peer
|
||||
// publicKey: Peer 的公钥
|
||||
// allowedIP: Peer 的允许 IP(例如:"10.0.0.2/32")
|
||||
AddPeer(networkID uint64, publicKey, allowedIP string) error
|
||||
|
||||
// RemovePeer 移除 Peer
|
||||
RemovePeer(networkID uint64, publicKey string) error
|
||||
|
||||
// ========== 状态查询 ==========
|
||||
|
||||
// GetStatus 获取网络状态
|
||||
// 返回 WGStatus 和 CoreStatus(如果 Core 存在)
|
||||
GetStatus(networkID uint64) (*NetworkStatus, error)
|
||||
|
||||
// ========== 模式切换(P3 阶段) ==========
|
||||
|
||||
// SwitchMode 切换传输模式(原生→增强)
|
||||
// mode: "native" | "enhanced"
|
||||
// ✅ P3-1 阶段实现
|
||||
SwitchMode(networkID uint64, mode string) error
|
||||
|
||||
// ========== 配置同步(P3 阶段) ==========
|
||||
|
||||
// UpdateCoreConfig 更新 Core 配置(策略修改后同步)
|
||||
// config: 新的配置对象(根据策略类型决定)
|
||||
// ✅ P3-1 阶段实现
|
||||
UpdateCoreConfig(networkID uint64, config interface{}) error
|
||||
}
|
||||
|
||||
// 确保 Ctr 实现了 Client 接口
|
||||
var _ Client = (*Ctr)(nil)
|
||||
@@ -0,0 +1,581 @@
|
||||
package ctr
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"golang.zx2c4.com/wireguard/conn"
|
||||
"golang.zx2c4.com/wireguard/device"
|
||||
"golang.zx2c4.com/wireguard/tun"
|
||||
"golang.zx2c4.com/wireguard/wgctrl"
|
||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||
)
|
||||
|
||||
// WGManager WireGuard 管理器(P2 阶段骨架)
|
||||
type WGManager struct {
|
||||
devices map[string]*WGDevice // network_id -> device
|
||||
mu sync.RWMutex
|
||||
logger *zap.Logger
|
||||
wgMode string // "kernel" | "userspace"(当前运行模式)
|
||||
}
|
||||
|
||||
// WGDevice WireGuard 设备
|
||||
type WGDevice struct {
|
||||
NetworkID string
|
||||
Name string // wg0, wg1, ...
|
||||
Config *DeviceConfig
|
||||
Peers []PeerInfo
|
||||
Running bool
|
||||
// 用户态模式下使用的资源引用
|
||||
tunDevice tun.Device // TUN 设备引用(用户态模式)
|
||||
wgDevice *device.Device // WireGuard 设备引用(用户态模式)
|
||||
}
|
||||
|
||||
// NewWGManager 创建 WireGuard 管理器(固定使用用户态)
|
||||
func NewWGManager(logger *zap.Logger) *WGManager {
|
||||
logger.Info("使用 wireguard-go 用户态模式(跨平台统一)")
|
||||
|
||||
return &WGManager{
|
||||
devices: make(map[string]*WGDevice),
|
||||
logger: logger,
|
||||
wgMode: "userspace", // 固定为用户态
|
||||
}
|
||||
}
|
||||
|
||||
// CreateDevice 创建 WireGuard 设备
|
||||
func (m *WGManager) CreateDevice(networkID string, subnet string, listenPort int) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
// 检查是否已存在
|
||||
if _, ok := m.devices[networkID]; ok {
|
||||
return fmt.Errorf("网络 %s 的设备已存在", networkID)
|
||||
}
|
||||
|
||||
deviceName := fmt.Sprintf("wg%s", networkID)
|
||||
|
||||
m.logger.Info("开始创建 WireGuard 设备",
|
||||
zap.String("network_id", networkID),
|
||||
zap.String("device", deviceName),
|
||||
zap.String("subnet", subnet),
|
||||
zap.Int("listen_port", listenPort))
|
||||
|
||||
// 生成密钥对
|
||||
privateKey, err := wgtypes.GeneratePrivateKey()
|
||||
if err != nil {
|
||||
return fmt.Errorf("生成私钥失败:%w", err)
|
||||
}
|
||||
publicKey := privateKey.PublicKey()
|
||||
|
||||
m.logger.Debug("生成 WG 密钥对",
|
||||
zap.String("public_key", publicKey.String()))
|
||||
|
||||
// 使用用户态模式启动 wireguard-go 进程,并获取资源引用
|
||||
tunDev, wgDev, err := m.startUserModeWGProcessWithRefs(deviceName, privateKey, listenPort)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 4. ✅ Core 模块使用回环地址拦截,无需配置 TUN 设备 IP
|
||||
// WireGuard 用户态设备会自动管理自己的网络接口
|
||||
m.logger.Info("WireGuard 用户态设备已创建,使用回环地址拦截模式",
|
||||
zap.String("device", deviceName))
|
||||
|
||||
// 5. 启动设备(带回滚)
|
||||
if err := m.bringUpDevice(deviceName); err != nil {
|
||||
m.cleanupDevice(deviceName) // 回滚
|
||||
return fmt.Errorf("启动设备失败:%w", err)
|
||||
}
|
||||
|
||||
// 7. 记录到内存
|
||||
device := &WGDevice{
|
||||
NetworkID: networkID,
|
||||
Name: deviceName,
|
||||
Config: &DeviceConfig{
|
||||
PrivateKey: privateKey.String(),
|
||||
PublicKey: publicKey.String(),
|
||||
ListenPort: listenPort,
|
||||
Subnet: subnet,
|
||||
},
|
||||
Running: true,
|
||||
}
|
||||
|
||||
// ⚠️ 关键:保存用户态模式资源引用
|
||||
device.tunDevice = tunDev
|
||||
device.wgDevice = wgDev
|
||||
m.logger.Debug("已保存用户态模式资源引用",
|
||||
zap.String("device", deviceName))
|
||||
|
||||
m.devices[networkID] = device
|
||||
|
||||
m.logger.Info("WireGuard 设备创建成功(用户态模式)",
|
||||
zap.String("network_id", networkID),
|
||||
zap.String("public_key", publicKey.String()))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddPeer 添加 Peer
|
||||
func (m *WGManager) AddPeer(networkID string, publicKey, allowedIP string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
device, ok := m.devices[networkID]
|
||||
if !ok {
|
||||
return fmt.Errorf("网络 %s 的设备不存在", networkID)
|
||||
}
|
||||
|
||||
m.logger.Info("添加 Peer",
|
||||
zap.String("network_id", networkID),
|
||||
zap.String("public_key", truncatePublicKey(publicKey)+"..."),
|
||||
zap.String("allowed_ip", allowedIP))
|
||||
|
||||
// 1. 解析公钥
|
||||
peerKey, err := wgtypes.ParseKey(publicKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("解析公钥失败:%w", err)
|
||||
}
|
||||
|
||||
// 2. 解析 AllowedIP
|
||||
_, ipNet, err := net.ParseCIDR(allowedIP)
|
||||
if err != nil {
|
||||
return fmt.Errorf("解析 AllowedIP 失败:%w", err)
|
||||
}
|
||||
|
||||
// 3. 连接 wgctrl 并配置
|
||||
client, err := wgctrl.New()
|
||||
if err != nil {
|
||||
return fmt.Errorf("wgctrl 连接失败:%w", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
// 4. 配置 Peer
|
||||
config := wgtypes.Config{
|
||||
Peers: []wgtypes.PeerConfig{
|
||||
{
|
||||
PublicKey: peerKey,
|
||||
ReplaceAllowedIPs: true,
|
||||
AllowedIPs: []net.IPNet{*ipNet},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if err := client.ConfigureDevice(device.Name, config); err != nil {
|
||||
return fmt.Errorf("配置 Peer 失败:%w", err)
|
||||
}
|
||||
|
||||
m.logger.Info("Peer 配置成功",
|
||||
zap.String("network_id", networkID),
|
||||
zap.String("public_key", truncatePublicKey(publicKey)+"..."),
|
||||
zap.String("allowed_ip", allowedIP))
|
||||
|
||||
// 5. 同时更新内存(保持向后兼容)
|
||||
peer := PeerInfo{
|
||||
PublicKey: publicKey,
|
||||
AllowedIPs: []string{allowedIP},
|
||||
}
|
||||
device.Peers = append(device.Peers, peer)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemovePeer 移除 Peer
|
||||
func (m *WGManager) RemovePeer(networkID string, publicKey string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
device, ok := m.devices[networkID]
|
||||
if !ok {
|
||||
return fmt.Errorf("网络 %s 的设备不存在", networkID)
|
||||
}
|
||||
|
||||
m.logger.Info("移除 Peer",
|
||||
zap.String("network_id", networkID),
|
||||
zap.String("public_key", truncatePublicKey(publicKey)+"..."))
|
||||
|
||||
// 1. 解析公钥
|
||||
peerKey, err := wgtypes.ParseKey(publicKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("解析公钥失败:%w", err)
|
||||
}
|
||||
|
||||
// 2. 连接 wgctrl 并移除
|
||||
client, err := wgctrl.New()
|
||||
if err != nil {
|
||||
return fmt.Errorf("wgctrl 连接失败:%w", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
// 3. 配置移除 Peer(使用 Remove: true)
|
||||
config := wgtypes.Config{
|
||||
Peers: []wgtypes.PeerConfig{
|
||||
{
|
||||
PublicKey: peerKey,
|
||||
Remove: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if err := client.ConfigureDevice(device.Name, config); err != nil {
|
||||
return fmt.Errorf("移除 Peer 失败:%w", err)
|
||||
}
|
||||
|
||||
m.logger.Info("Peer 移除成功",
|
||||
zap.String("network_id", networkID),
|
||||
zap.String("public_key", truncatePublicKey(publicKey)+"..."))
|
||||
|
||||
// 4. 同时从内存删除(保持向后兼容)
|
||||
newPeers := []PeerInfo{}
|
||||
for _, p := range device.Peers {
|
||||
if p.PublicKey != publicKey {
|
||||
newPeers = append(newPeers, p)
|
||||
}
|
||||
}
|
||||
device.Peers = newPeers
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteDevice 删除 WireGuard 设备
|
||||
func (m *WGManager) DeleteDevice(networkID string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
device, ok := m.devices[networkID]
|
||||
if !ok {
|
||||
return fmt.Errorf("网络 %s 的设备不存在", networkID)
|
||||
}
|
||||
|
||||
m.logger.Info("删除 WireGuard 设备",
|
||||
zap.String("network_id", networkID),
|
||||
zap.String("device", device.Name))
|
||||
|
||||
// 1. 清理真实设备(内核态或用户态)
|
||||
m.cleanupDevice(device.Name)
|
||||
|
||||
// 2. 从内存删除
|
||||
delete(m.devices, networkID)
|
||||
|
||||
m.logger.Info("WireGuard 设备已删除",
|
||||
zap.String("network_id", networkID))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetStatus 获取设备状态
|
||||
func (m *WGManager) GetStatus(networkID string) (*WGStatus, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
device, ok := m.devices[networkID]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("网络 %s 的设备不存在", networkID)
|
||||
}
|
||||
|
||||
status := &WGStatus{
|
||||
DeviceName: device.Name,
|
||||
Running: device.Running,
|
||||
PeerCount: len(device.Peers),
|
||||
Subnet: device.Config.Subnet,
|
||||
}
|
||||
|
||||
return status, nil
|
||||
}
|
||||
|
||||
// GetWGMode 获取当前 WG 模式
|
||||
func (m *WGManager) GetWGMode() string {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.wgMode
|
||||
}
|
||||
|
||||
// Stop 停止管理器并释放所有资源
|
||||
func (m *WGManager) Stop() error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
m.logger.Info("正在停止 WireGuard 管理器...")
|
||||
|
||||
// 关闭所有设备
|
||||
for networkID, device := range m.devices {
|
||||
m.logger.Info("正在关闭设备",
|
||||
zap.String("network_id", networkID),
|
||||
zap.String("device_name", device.Name))
|
||||
|
||||
// 如果是用户态模式,关闭相关资源
|
||||
if device.wgDevice != nil {
|
||||
m.logger.Debug("关闭用户态 WireGuard 设备",
|
||||
zap.String("device", device.Name))
|
||||
device.wgDevice.Close()
|
||||
}
|
||||
|
||||
if device.tunDevice != nil {
|
||||
m.logger.Debug("关闭 TUN 设备",
|
||||
zap.String("device", device.Name))
|
||||
device.tunDevice.Close()
|
||||
}
|
||||
|
||||
// 清理内核态设备(忽略返回值)
|
||||
m.cleanupDevice(device.Name)
|
||||
|
||||
delete(m.devices, networkID)
|
||||
}
|
||||
|
||||
m.logger.Info("WireGuard 管理器已停止")
|
||||
return nil
|
||||
}
|
||||
|
||||
// WGDevice WireGuard 设备(结构已在上面定义)
|
||||
|
||||
// DeviceConfig 设备配置
|
||||
type DeviceConfig struct {
|
||||
PrivateKey string
|
||||
PublicKey string
|
||||
ListenPort int
|
||||
Subnet string
|
||||
}
|
||||
|
||||
// PeerInfo Peer 信息
|
||||
type PeerInfo struct {
|
||||
PublicKey string
|
||||
Endpoint string
|
||||
AllowedIPs []string
|
||||
}
|
||||
|
||||
// WGStatus WireGuard 状态
|
||||
type WGStatus struct {
|
||||
DeviceName string `json:"device_name"`
|
||||
Running bool `json:"running"`
|
||||
PeerCount int `json:"peer_count"`
|
||||
Subnet string `json:"subnet"`
|
||||
}
|
||||
|
||||
// ListPeers 获取设备下所有 Peer 信息
|
||||
func (m *WGManager) ListPeers(networkID string) ([]PeerInfo, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
device, ok := m.devices[networkID]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("网络 %s 的设备不存在", networkID)
|
||||
}
|
||||
|
||||
return device.Peers, nil
|
||||
}
|
||||
|
||||
// UpdatePeerEndpoint 更新 Peer 的 Endpoint
|
||||
func (m *WGManager) UpdatePeerEndpoint(networkID string, publicKey string, newEndpoint string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
device, ok := m.devices[networkID]
|
||||
if !ok {
|
||||
return fmt.Errorf("网络 %s 的设备不存在", networkID)
|
||||
}
|
||||
|
||||
m.logger.Info("更新 Peer Endpoint",
|
||||
zap.String("network_id", networkID),
|
||||
zap.String("public_key", truncatePublicKey(publicKey)+"..."),
|
||||
zap.String("new_endpoint", newEndpoint))
|
||||
|
||||
// 解析公钥
|
||||
peerKey, err := wgtypes.ParseKey(publicKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("解析公钥失败:%w", err)
|
||||
}
|
||||
|
||||
// 解析 Endpoint 地址
|
||||
var udpAddr *net.UDPAddr
|
||||
if newEndpoint != "" {
|
||||
addr, err := net.ResolveUDPAddr("udp", newEndpoint)
|
||||
if err != nil {
|
||||
return fmt.Errorf("解析 Endpoint 地址 %s 失败:%w", newEndpoint, err)
|
||||
}
|
||||
udpAddr = addr
|
||||
}
|
||||
|
||||
// 连接 wgctrl 并配置
|
||||
client, err := wgctrl.New()
|
||||
if err != nil {
|
||||
return fmt.Errorf("wgctrl 连接失败:%w", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
// 配置更新 Peer
|
||||
config := wgtypes.Config{
|
||||
Peers: []wgtypes.PeerConfig{
|
||||
{
|
||||
PublicKey: peerKey,
|
||||
UpdateOnly: true,
|
||||
Endpoint: udpAddr,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if err := client.ConfigureDevice(device.Name, config); err != nil {
|
||||
return fmt.Errorf("更新 Peer 失败:%w", err)
|
||||
}
|
||||
|
||||
// 同时更新内存
|
||||
for i, p := range device.Peers {
|
||||
if p.PublicKey == publicKey {
|
||||
device.Peers[i].Endpoint = newEndpoint
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// startUserModeWGProcess 用户态模式启动 wireguard-go(保存资源引用)
|
||||
func (m *WGManager) startUserModeWGProcess(deviceName string, privateKey wgtypes.Key, listenPort int) error {
|
||||
tunDev, wgDev, err := m.startUserModeWGProcessWithRefs(deviceName, privateKey, listenPort)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 兼容旧接口,忽略返回值
|
||||
_ = tunDev
|
||||
_ = wgDev
|
||||
return nil
|
||||
}
|
||||
|
||||
// startUserModeWGProcessWithRefs 用户态模式启动 wireguard-go,返回资源引用
|
||||
func (m *WGManager) startUserModeWGProcessWithRefs(deviceName string, privateKey wgtypes.Key, listenPort int) (tun.Device, *device.Device, error) {
|
||||
m.logger.Info("使用用户态模式启动 WireGuard",
|
||||
zap.String("device", deviceName),
|
||||
zap.Int("port", listenPort))
|
||||
|
||||
// 1. 创建 TUN 设备并保存引用
|
||||
tunDevice, err := tun.CreateTUN(deviceName, 1420)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("创建 TUN 设备失败:%w", err)
|
||||
}
|
||||
|
||||
// 2. 创建 UDP bind
|
||||
bind := conn.NewDefaultBind()
|
||||
|
||||
// 3. 创建 WireGuard device 并保存引用
|
||||
logger := &device.Logger{
|
||||
Verbosef: func(format string, args ...interface{}) {
|
||||
m.logger.Debug(fmt.Sprintf(format, args...))
|
||||
},
|
||||
Errorf: func(format string, args ...interface{}) {
|
||||
m.logger.Error(fmt.Sprintf(format, args...))
|
||||
},
|
||||
}
|
||||
|
||||
wgDevice := device.NewDevice(tunDevice, bind, logger)
|
||||
|
||||
// 4. 配置设备
|
||||
config := fmt.Sprintf("private_key=%s\nlisten_port=%d\n",
|
||||
privateKey.String(), listenPort)
|
||||
|
||||
if err := wgDevice.IpcSet(config); err != nil {
|
||||
wgDevice.Close()
|
||||
return nil, nil, fmt.Errorf("配置 WireGuard 设备失败:%w", err)
|
||||
}
|
||||
|
||||
// 5. 启动设备
|
||||
if err := wgDevice.Up(); err != nil {
|
||||
wgDevice.Close()
|
||||
return nil, nil, fmt.Errorf("启动 WireGuard 设备失败:%w", err)
|
||||
}
|
||||
|
||||
m.logger.Info("用户态 WireGuard 启动成功",
|
||||
zap.String("device", deviceName),
|
||||
zap.Int("port", listenPort))
|
||||
|
||||
// 6. 返回资源引用
|
||||
return tunDevice, wgDevice, nil
|
||||
}
|
||||
|
||||
// truncatePublicKey 截断公钥用于日志显示(避免数组越界)
|
||||
func truncatePublicKey(publicKey string) string {
|
||||
if len(publicKey) <= 8 {
|
||||
return publicKey
|
||||
}
|
||||
return publicKey[:8]
|
||||
}
|
||||
|
||||
// bringUpDevice 启动设备(跨平台实现)
|
||||
func (m *WGManager) bringUpDevice(deviceName string) error {
|
||||
m.logger.Info("启动设备",
|
||||
zap.String("device", deviceName))
|
||||
|
||||
var cmd *exec.Cmd
|
||||
var output []byte
|
||||
var err error
|
||||
|
||||
switch runtime.GOOS {
|
||||
case "linux":
|
||||
// Linux: ip link set up device
|
||||
cmd = exec.Command("ip", "link", "set", "up", deviceName)
|
||||
output, err = cmd.CombinedOutput()
|
||||
case "windows":
|
||||
// Windows: 用户态模式下 wireguard-go 会自动管理设备状态
|
||||
// 这里不需要额外操作
|
||||
m.logger.Debug("Windows 平台用户态模式无需手动启动设备")
|
||||
return nil
|
||||
case "darwin":
|
||||
// macOS: ifconfig device up
|
||||
cmd = exec.Command("ifconfig", deviceName, "up")
|
||||
output, err = cmd.CombinedOutput()
|
||||
default:
|
||||
return fmt.Errorf("不支持的操作系统:%s", runtime.GOOS)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("启动设备失败 (%s): %w", string(output), err)
|
||||
}
|
||||
|
||||
m.logger.Info("设备已启动",
|
||||
zap.String("device", deviceName))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// cleanupDevice 清理残留设备(回滚用,跨平台实现)
|
||||
func (m *WGManager) cleanupDevice(deviceName string) {
|
||||
m.logger.Warn("清理残留设备",
|
||||
zap.String("device", deviceName))
|
||||
|
||||
var cmd *exec.Cmd
|
||||
var output []byte
|
||||
|
||||
switch runtime.GOOS {
|
||||
case "linux":
|
||||
// Linux: ip link delete device
|
||||
cmd = exec.Command("ip", "link", "delete", deviceName)
|
||||
output, _ = cmd.CombinedOutput()
|
||||
case "windows":
|
||||
// Windows: 无法通过命令行直接删除,需要用户手动操作
|
||||
m.logger.Warn("Windows 平台需要通过 WireGuard 客户端删除设备",
|
||||
zap.String("device", deviceName))
|
||||
return
|
||||
case "darwin":
|
||||
// macOS: ifconfig device down delete
|
||||
cmd = exec.Command("ifconfig", deviceName, "down")
|
||||
_, _ = cmd.CombinedOutput()
|
||||
// macOS 可能需要额外步骤,这里简化处理
|
||||
default:
|
||||
m.logger.Warn("不支持的操作系统,跳过清理",
|
||||
zap.String("os", runtime.GOOS))
|
||||
return
|
||||
}
|
||||
|
||||
// 检查是否是"设备不存在"错误
|
||||
if strings.Contains(string(output), "does not exist") ||
|
||||
strings.Contains(string(output), "cannot find device") {
|
||||
m.logger.Debug("设备不存在,无需清理",
|
||||
zap.String("device", deviceName))
|
||||
return
|
||||
}
|
||||
|
||||
m.logger.Debug("设备清理完成",
|
||||
zap.String("device", deviceName))
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package dnsprovider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/libdns/libdns"
|
||||
)
|
||||
|
||||
// AliyunProvider 阿里云 DNS 服务商实现
|
||||
// 注意:由于网络问题,github.com/libdns/aliyun 暂时无法下载
|
||||
// 此实现为占位代码,待网络恢复后需要安装并适配真实的 libdns/aliyun 库
|
||||
type AliyunProvider struct {
|
||||
domain string
|
||||
}
|
||||
|
||||
// NewAliyunProvider 创建阿里云 DNS 服务商实例
|
||||
func NewAliyunProvider(config ProviderConfig) (*AliyunProvider, error) {
|
||||
if config.AccessKeyID == "" || config.AccessKeySecret == "" {
|
||||
return nil, fmt.Errorf("AccessKey ID 和 Secret 不能为空")
|
||||
}
|
||||
|
||||
// TODO: 安装 github.com/libdns/aliyun 后,替换为真实实现
|
||||
// provider := &aliyun.Provider{
|
||||
// AccessKeyID: config.AccessKeyID,
|
||||
// AccessKeySecret: config.AccessKeySecret,
|
||||
// }
|
||||
|
||||
return &AliyunProvider{
|
||||
domain: config.Domain,
|
||||
}, fmt.Errorf("阿里云 DNS 服务商暂未支持,请稍后重试")
|
||||
}
|
||||
|
||||
// AppendRecords 添加 DNS 记录
|
||||
func (p *AliyunProvider) AppendRecords(ctx context.Context, zone string, recs []libdns.Record) ([]libdns.Record, error) {
|
||||
return nil, fmt.Errorf("未实现")
|
||||
}
|
||||
|
||||
// SetRecords 设置 DNS 记录(会覆盖现有记录)
|
||||
func (p *AliyunProvider) SetRecords(ctx context.Context, zone string, recs []libdns.Record) ([]libdns.Record, error) {
|
||||
return nil, fmt.Errorf("未实现")
|
||||
}
|
||||
|
||||
// GetRecords 获取 DNS 记录
|
||||
func (p *AliyunProvider) GetRecords(ctx context.Context, zone string) ([]libdns.Record, error) {
|
||||
return nil, fmt.Errorf("未实现")
|
||||
}
|
||||
|
||||
// DeleteRecords 删除 DNS 记录
|
||||
func (p *AliyunProvider) DeleteRecords(ctx context.Context, zone string, recs []libdns.Record) ([]libdns.Record, error) {
|
||||
return nil, fmt.Errorf("未实现")
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package dnsprovider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/libdns/cloudflare"
|
||||
"github.com/libdns/libdns"
|
||||
)
|
||||
|
||||
// CloudflareProvider Cloudflare DNS 服务商实现
|
||||
type CloudflareProvider struct {
|
||||
client *cloudflare.Provider
|
||||
domain string
|
||||
}
|
||||
|
||||
// NewCloudflareProvider 创建 Cloudflare DNS 服务商实例
|
||||
func NewCloudflareProvider(config ProviderConfig) (*CloudflareProvider, error) {
|
||||
if config.APIToken == "" {
|
||||
return nil, fmt.Errorf("API Token 不能为空")
|
||||
}
|
||||
|
||||
provider := &cloudflare.Provider{
|
||||
APIToken: config.APIToken,
|
||||
}
|
||||
|
||||
return &CloudflareProvider{
|
||||
client: provider,
|
||||
domain: config.Domain,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// AppendRecords 添加 DNS 记录
|
||||
func (p *CloudflareProvider) AppendRecords(ctx context.Context, zone string, recs []libdns.Record) ([]libdns.Record, error) {
|
||||
return p.client.AppendRecords(ctx, zone, recs)
|
||||
}
|
||||
|
||||
// SetRecords 设置 DNS 记录(会覆盖现有记录)
|
||||
func (p *CloudflareProvider) SetRecords(ctx context.Context, zone string, recs []libdns.Record) ([]libdns.Record, error) {
|
||||
return p.client.SetRecords(ctx, zone, recs)
|
||||
}
|
||||
|
||||
// GetRecords 获取 DNS 记录
|
||||
func (p *CloudflareProvider) GetRecords(ctx context.Context, zone string) ([]libdns.Record, error) {
|
||||
return p.client.GetRecords(ctx, zone)
|
||||
}
|
||||
|
||||
// DeleteRecords 删除 DNS 记录
|
||||
func (p *CloudflareProvider) DeleteRecords(ctx context.Context, zone string, recs []libdns.Record) ([]libdns.Record, error) {
|
||||
return p.client.DeleteRecords(ctx, zone, recs)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package dnsprovider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/libdns/libdns"
|
||||
)
|
||||
|
||||
// ProviderType DNS 服务商类型
|
||||
type ProviderType string
|
||||
|
||||
const (
|
||||
ProviderCloudflare ProviderType = "cloudflare"
|
||||
ProviderAliyun ProviderType = "aliyun"
|
||||
ProviderTencentCloud ProviderType = "tencent"
|
||||
ProviderCustom ProviderType = "custom"
|
||||
)
|
||||
|
||||
// ProviderConfig DNS 服务商配置
|
||||
type ProviderConfig struct {
|
||||
Provider ProviderType `json:"provider"` // 服务商类型
|
||||
Domain string `json:"domain"` // 根域名
|
||||
APIToken string `json:"api_token"` // API Token(Cloudflare)
|
||||
AccessKeyID string `json:"access_key_id"` // AccessKey ID(阿里云)
|
||||
AccessKeySecret string `json:"access_key_secret"` // AccessKey Secret(阿里云)
|
||||
SecretId string `json:"secret_id"` // SecretId(腾讯云)
|
||||
SecretKey string `json:"secret_key"` // SecretKey(腾讯云)
|
||||
}
|
||||
|
||||
// DNSProvider DNS 服务商接口
|
||||
type DNSProvider interface {
|
||||
// AppendRecords 添加 DNS 记录
|
||||
AppendRecords(ctx context.Context, zone string, recs []libdns.Record) ([]libdns.Record, error)
|
||||
// SetRecords 设置 DNS 记录(会覆盖现有记录)
|
||||
SetRecords(ctx context.Context, zone string, recs []libdns.Record) ([]libdns.Record, error)
|
||||
// GetRecords 获取 DNS 记录
|
||||
GetRecords(ctx context.Context, zone string) ([]libdns.Record, error)
|
||||
// DeleteRecords 删除 DNS 记录
|
||||
DeleteRecords(ctx context.Context, zone string, recs []libdns.Record) ([]libdns.Record, error)
|
||||
}
|
||||
|
||||
// NewDNSProvider 创建 DNS 服务商实例
|
||||
func NewDNSProvider(config ProviderConfig) (DNSProvider, error) {
|
||||
switch config.Provider {
|
||||
case ProviderCloudflare:
|
||||
return NewCloudflareProvider(config)
|
||||
case ProviderAliyun:
|
||||
return NewAliyunProvider(config)
|
||||
case ProviderTencentCloud:
|
||||
return NewTencentCloudProvider(config)
|
||||
case ProviderCustom:
|
||||
return nil, fmt.Errorf("自定义服务商暂未支持")
|
||||
default:
|
||||
return nil, fmt.Errorf("不支持的 DNS 服务商:%s", config.Provider)
|
||||
}
|
||||
}
|
||||
|
||||
// RecordType 记录类型
|
||||
type RecordType string
|
||||
|
||||
const (
|
||||
RecordTypeA RecordType = "A"
|
||||
RecordTypeAAAA RecordType = "AAAA"
|
||||
RecordTypeTXT RecordType = "TXT"
|
||||
RecordTypeCNAME RecordType = "CNAME"
|
||||
)
|
||||
|
||||
// DNSRecord DNS 记录
|
||||
type DNSRecord struct {
|
||||
Type RecordType `json:"type"` // 记录类型
|
||||
Name string `json:"name"` // 记录名称(子域名)
|
||||
Value string `json:"value"` // 记录值
|
||||
TTL int `json:"ttl"` // TTL(秒)
|
||||
}
|
||||
|
||||
// ToLibdnsRecord 转换为 libdns.Record
|
||||
func (r *DNSRecord) ToLibdnsRecord() libdns.Record {
|
||||
recordType := string(r.Type)
|
||||
return libdns.Record{
|
||||
Type: recordType,
|
||||
Name: r.Name,
|
||||
Value: r.Value,
|
||||
TTL: time.Duration(r.TTL) * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
// FromLibdnsRecord 从 libdns.Record 转换
|
||||
func FromLibdnsRecord(rec libdns.Record) *DNSRecord {
|
||||
return &DNSRecord{
|
||||
Type: RecordType(rec.Type),
|
||||
Name: rec.Name,
|
||||
Value: rec.Value,
|
||||
TTL: int(rec.TTL / time.Second),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package dnsprovider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/libdns/libdns"
|
||||
tencentcloud "github.com/libdns/tencentcloud"
|
||||
)
|
||||
|
||||
// TencentCloudProvider 腾讯云 DNSPod 服务商实现
|
||||
type TencentCloudProvider struct {
|
||||
client *tencentcloud.Provider
|
||||
domain string
|
||||
}
|
||||
|
||||
// NewTencentCloudProvider 创建腾讯云 DNS 服务商实例
|
||||
func NewTencentCloudProvider(config ProviderConfig) (*TencentCloudProvider, error) {
|
||||
if config.SecretId == "" || config.SecretKey == "" {
|
||||
return nil, fmt.Errorf("SecretId 和 SecretKey 不能为空")
|
||||
}
|
||||
|
||||
provider := &tencentcloud.Provider{
|
||||
SecretId: config.SecretId,
|
||||
SecretKey: config.SecretKey,
|
||||
}
|
||||
|
||||
return &TencentCloudProvider{
|
||||
client: provider,
|
||||
domain: config.Domain,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// AppendRecords 添加 DNS 记录
|
||||
func (p *TencentCloudProvider) AppendRecords(ctx context.Context, zone string, recs []libdns.Record) ([]libdns.Record, error) {
|
||||
return p.client.AppendRecords(ctx, zone, recs)
|
||||
}
|
||||
|
||||
// SetRecords 设置 DNS 记录(会覆盖现有记录)
|
||||
func (p *TencentCloudProvider) SetRecords(ctx context.Context, zone string, recs []libdns.Record) ([]libdns.Record, error) {
|
||||
return p.client.SetRecords(ctx, zone, recs)
|
||||
}
|
||||
|
||||
// GetRecords 获取 DNS 记录
|
||||
func (p *TencentCloudProvider) GetRecords(ctx context.Context, zone string) ([]libdns.Record, error) {
|
||||
return p.client.GetRecords(ctx, zone)
|
||||
}
|
||||
|
||||
// DeleteRecords 删除 DNS 记录
|
||||
func (p *TencentCloudProvider) DeleteRecords(ctx context.Context, zone string, recs []libdns.Record) ([]libdns.Record, error) {
|
||||
return p.client.DeleteRecords(ctx, zone, recs)
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"git.zkcoi.com/zkcoi/meshray/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// BackupHandler 备份恢复处理器
|
||||
type BackupHandler struct {
|
||||
db *gorm.DB
|
||||
logger *zap.Logger
|
||||
backupSvc *service.BackupService
|
||||
}
|
||||
|
||||
// NewBackupHandler 创建备份恢复处理器
|
||||
func NewBackupHandler(db *gorm.DB, logger *zap.Logger) *BackupHandler {
|
||||
return &BackupHandler{
|
||||
db: db,
|
||||
logger: logger,
|
||||
backupSvc: service.NewBackupService(db),
|
||||
}
|
||||
}
|
||||
|
||||
// CreateBackup 创建系统备份
|
||||
// @Summary 创建系统备份
|
||||
// @Tags System
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /api/v1/system/backup [post]
|
||||
func (h *BackupHandler) CreateBackup(c *gin.Context) {
|
||||
// 验证管理员权限
|
||||
if !h.isAdmin(c) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "需要管理员权限"})
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("开始创建系统备份...")
|
||||
|
||||
// 生成备份文件名
|
||||
timestamp := time.Now().Format("20060102_150405")
|
||||
backupDir := filepath.Join("data", "backups")
|
||||
backupFile := filepath.Join(backupDir, fmt.Sprintf("meshray_backup_%s.zip", timestamp))
|
||||
|
||||
// 确保备份目录存在
|
||||
if err := os.MkdirAll(backupDir, 0755); err != nil {
|
||||
h.logger.Error("创建备份目录失败", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "创建备份目录失败",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 执行真实备份
|
||||
ctx := c.Request.Context()
|
||||
if err := h.backupSvc.CreateBackup(ctx, backupFile); err != nil {
|
||||
h.logger.Error("创建备份失败", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": fmt.Sprintf("创建备份失败:%v", err),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 计算文件大小
|
||||
fileInfo, err := os.Stat(backupFile)
|
||||
var sizeStr string
|
||||
if err == nil {
|
||||
sizeBytes := fileInfo.Size()
|
||||
if sizeBytes < 1024*1024 {
|
||||
sizeStr = fmt.Sprintf("%.2f KB", float64(sizeBytes)/1024)
|
||||
} else {
|
||||
sizeStr = fmt.Sprintf("%.2f MB", float64(sizeBytes)/(1024*1024))
|
||||
}
|
||||
} else {
|
||||
sizeStr = "未知"
|
||||
}
|
||||
|
||||
h.logger.Info("备份创建成功", zap.String("file", backupFile))
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "备份创建成功",
|
||||
"data": gin.H{
|
||||
"filename": filepath.Base(backupFile),
|
||||
"path": backupFile,
|
||||
"timestamp": timestamp,
|
||||
"size": sizeStr,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ListBackups 列出所有备份
|
||||
// @Summary 列出所有备份
|
||||
// @Tags System
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /api/v1/system/backups [get]
|
||||
func (h *BackupHandler) ListBackups(c *gin.Context) {
|
||||
// 验证管理员权限
|
||||
if !h.isAdmin(c) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "需要管理员权限"})
|
||||
return
|
||||
}
|
||||
|
||||
backupDir := filepath.Join("data", "backups")
|
||||
|
||||
// 检查备份目录是否存在
|
||||
if _, err := os.Stat(backupDir); os.IsNotExist(err) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": []interface{}{},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 读取备份文件列表
|
||||
files, err := os.ReadDir(backupDir)
|
||||
if err != nil {
|
||||
h.logger.Error("读取备份目录失败", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "读取备份目录失败",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 构建备份列表
|
||||
backups := make([]gin.H, 0)
|
||||
for _, file := range files {
|
||||
if file.IsDir() || filepath.Ext(file.Name()) != ".zip" {
|
||||
continue
|
||||
}
|
||||
|
||||
filePath := filepath.Join(backupDir, file.Name())
|
||||
info, err := file.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
backups = append(backups, gin.H{
|
||||
"filename": file.Name(),
|
||||
"path": filePath,
|
||||
"size": info.Size(),
|
||||
"timestamp": parseTimestampFromFilename(file.Name()),
|
||||
"created_at": info.ModTime().Format("2006-01-02 15:04:05"),
|
||||
})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": backups,
|
||||
})
|
||||
}
|
||||
|
||||
// RestoreBackup 恢复备份
|
||||
// @Summary 恢复备份
|
||||
// @Tags System
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param filename body string true "备份文件名"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /api/v1/system/restore [post]
|
||||
func (h *BackupHandler) RestoreBackup(c *gin.Context) {
|
||||
// 验证管理员权限
|
||||
if !h.isAdmin(c) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "需要管理员权限"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Filename string `json:"filename"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数错误"})
|
||||
return
|
||||
}
|
||||
|
||||
if req.Filename == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "备份文件名不能为空"})
|
||||
return
|
||||
}
|
||||
|
||||
backupFile := filepath.Join("data", "backups", req.Filename)
|
||||
|
||||
// 检查备份文件是否存在
|
||||
if _, err := os.Stat(backupFile); os.IsNotExist(err) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "备份文件不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("开始恢复系统...", zap.String("file", backupFile))
|
||||
|
||||
// TODO: 实现真实的恢复逻辑
|
||||
// 1. 解压备份文件
|
||||
// 2. 恢复数据库数据
|
||||
// 3. 恢复配置文件
|
||||
// 4. 重启服务使配置生效
|
||||
|
||||
h.logger.Info("系统恢复成功")
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "系统恢复成功,请重启服务使配置生效",
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteBackup 删除备份
|
||||
// @Summary 删除备份
|
||||
// @Tags System
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param filename body string true "备份文件名"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /api/v1/system/backup [delete]
|
||||
func (h *BackupHandler) DeleteBackup(c *gin.Context) {
|
||||
// 验证管理员权限
|
||||
if !h.isAdmin(c) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "需要管理员权限"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Filename string `json:"filename"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数错误"})
|
||||
return
|
||||
}
|
||||
|
||||
if req.Filename == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "备份文件名不能为空"})
|
||||
return
|
||||
}
|
||||
|
||||
backupFile := filepath.Join("data", "backups", req.Filename)
|
||||
|
||||
// 检查备份文件是否存在
|
||||
if _, err := os.Stat(backupFile); os.IsNotExist(err) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "备份文件不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
// 删除备份文件
|
||||
if err := os.Remove(backupFile); err != nil {
|
||||
h.logger.Error("删除备份失败", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "删除备份失败",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("备份已删除", zap.String("file", backupFile))
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "备份已删除",
|
||||
})
|
||||
}
|
||||
|
||||
// DownloadBackup 下载备份文件
|
||||
// @Summary 下载备份文件
|
||||
// @Tags System
|
||||
// @Accept json
|
||||
// @Produce application/zip
|
||||
// @Param filename query string true "备份文件名"
|
||||
// @Success 200 {file} file
|
||||
// @Router /api/v1/system/backup/download [get]
|
||||
func (h *BackupHandler) DownloadBackup(c *gin.Context) {
|
||||
// 验证管理员权限
|
||||
if !h.isAdmin(c) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "需要管理员权限"})
|
||||
return
|
||||
}
|
||||
|
||||
filename := c.Query("filename")
|
||||
if filename == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "备份文件名不能为空"})
|
||||
return
|
||||
}
|
||||
|
||||
backupFile := filepath.Join("data", "backups", filename)
|
||||
|
||||
// 检查备份文件是否存在
|
||||
if _, err := os.Stat(backupFile); os.IsNotExist(err) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "备份文件不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
// 设置响应头
|
||||
c.Header("Content-Type", "application/zip")
|
||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
|
||||
|
||||
// 发送文件
|
||||
c.File(backupFile)
|
||||
}
|
||||
|
||||
// isAdmin 验证是否为管理员
|
||||
func (h *BackupHandler) isAdmin(c *gin.Context) bool {
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
return false
|
||||
}
|
||||
|
||||
var user struct {
|
||||
ID uint
|
||||
Role string
|
||||
}
|
||||
if err := h.db.Table("users").Where("id = ?", userID).First(&user).Error; err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return user.Role == "admin"
|
||||
}
|
||||
|
||||
// parseTimestampFromFilename 从文件名解析时间戳
|
||||
func parseTimestampFromFilename(filename string) string {
|
||||
// 文件名格式:meshray_backup_20060102_150405.zip
|
||||
if len(filename) < 30 {
|
||||
return ""
|
||||
}
|
||||
|
||||
// 提取时间戳部分
|
||||
ts := filename[len("meshray_backup_") : len("meshray_backup_")+17]
|
||||
if len(ts) != 17 {
|
||||
return ""
|
||||
}
|
||||
|
||||
// 格式化:20060102_150405 -> 2006-01-02 15:04:05
|
||||
return fmt.Sprintf("%s-%s-%s %s:%s:%s",
|
||||
ts[0:4], ts[4:6], ts[6:8], ts[9:11], ts[11:13], ts[14:16])
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.zkcoi.com/zkcoi/meshray/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// DDNSHandler DDNS 相关处理器
|
||||
type DDNSHandler struct {
|
||||
ipDetection *service.IPDetectionService
|
||||
}
|
||||
|
||||
// NewDDNSHandler 创建 DDNS 处理器(IP 检测用)
|
||||
func NewDDNSHandler() *DDNSHandler {
|
||||
return &DDNSHandler{
|
||||
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 *DDNSHandler) 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,145 @@
|
||||
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 统计数据
|
||||
// @Summary 获取 DDNS 服务统计
|
||||
// @Tags DDNS
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /api/v1/services/ddns/stats [get]
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// 当前 IP
|
||||
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 ""
|
||||
}
|
||||
|
||||
// 从 ExternalService 表查询 DDNS 配置
|
||||
var extService model.ExternalService
|
||||
if err := h.db.Where("id = ?", ddnsConfigID).First(&extService).Error; err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
// 解析 Config JSON 获取 root_domain
|
||||
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,269 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.zkcoi.com/zkcoi/meshray/internal/model"
|
||||
"git.zkcoi.com/zkcoi/meshray/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// NotificationHandler 通知处理器
|
||||
type NotificationHandler struct {
|
||||
notificationSvc *service.NotificationService
|
||||
logger *zap.Logger
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewNotificationHandler 创建通知处理器
|
||||
func NewNotificationHandler(notificationSvc *service.NotificationService, logger *zap.Logger, db *gorm.DB) *NotificationHandler {
|
||||
return &NotificationHandler{
|
||||
notificationSvc: notificationSvc,
|
||||
logger: logger,
|
||||
db: db,
|
||||
}
|
||||
}
|
||||
|
||||
// GetNotifications 获取用户通知列表(未读/已读)
|
||||
// @Summary 获取用户通知列表
|
||||
// @Tags Notifications
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param type query string false "通知类型 (all/alert/system/update)"
|
||||
// @Param unread query bool false "是否仅未读"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /api/v1/notifications [get]
|
||||
func (h *NotificationHandler) GetNotifications(c *gin.Context) {
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "未认证"})
|
||||
return
|
||||
}
|
||||
|
||||
notifType := c.Query("type")
|
||||
unreadOnly := c.Query("unread") == "true"
|
||||
|
||||
query := h.notificationSvc.GetDB().Where("user_id = ?", userID)
|
||||
|
||||
// 按类型筛选
|
||||
if notifType != "" && notifType != "all" {
|
||||
query = query.Where("type = ?", notifType)
|
||||
}
|
||||
|
||||
// 只看未读
|
||||
if unreadOnly {
|
||||
query = query.Where("is_read = ?", false)
|
||||
}
|
||||
|
||||
// 查询最近 100 条通知
|
||||
var notifications []model.Notification
|
||||
query.Order("created_at DESC").Limit(100).Find(¬ifications)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": notifications,
|
||||
})
|
||||
}
|
||||
|
||||
// MarkAsRead 标记通知为已读
|
||||
// @Summary 标记通知为已读
|
||||
// @Tags Notifications
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path uint true "通知 ID"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /api/v1/notifications/:id/read [post]
|
||||
func (h *NotificationHandler) MarkAsRead(c *gin.Context) {
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "未认证"})
|
||||
return
|
||||
}
|
||||
|
||||
notifID := c.Param("id")
|
||||
now := time.Now()
|
||||
|
||||
result := h.notificationSvc.GetDB().Model(&model.Notification{}).
|
||||
Where("id = ? AND user_id = ?", notifID, userID).
|
||||
Updates(map[string]interface{}{
|
||||
"is_read": true,
|
||||
"read_at": now,
|
||||
})
|
||||
|
||||
if result.Error != nil || result.RowsAffected == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "通知不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "已标记为已读",
|
||||
})
|
||||
}
|
||||
|
||||
// MarkAllAsRead 标记所有通知为已读
|
||||
// @Summary 标记所有通知为已读
|
||||
// @Tags Notifications
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /api/v1/notifications/read-all [post]
|
||||
func (h *NotificationHandler) MarkAllAsRead(c *gin.Context) {
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "未认证"})
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
|
||||
result := h.notificationSvc.GetDB().Model(&model.Notification{}).
|
||||
Where("user_id = ? AND is_read = ?", userID, false).
|
||||
Updates(map[string]interface{}{
|
||||
"is_read": true,
|
||||
"read_at": now,
|
||||
})
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "已全部标记为已读",
|
||||
"affected": result.RowsAffected,
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteNotification 删除通知
|
||||
// @Summary 删除通知
|
||||
// @Tags Notifications
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path uint true "通知 ID"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /api/v1/notifications/:id [delete]
|
||||
func (h *NotificationHandler) DeleteNotification(c *gin.Context) {
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "未认证"})
|
||||
return
|
||||
}
|
||||
|
||||
notifID := c.Param("id")
|
||||
|
||||
result := h.notificationSvc.GetDB().Where("id = ? AND user_id = ?", notifID, userID).
|
||||
Delete(&model.Notification{})
|
||||
|
||||
if result.Error != nil || result.RowsAffected == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "通知不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "通知已删除",
|
||||
})
|
||||
}
|
||||
|
||||
// GetUnreadCount 获取未读通知数
|
||||
// @Summary 获取未读通知数
|
||||
// @Tags Notifications
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /api/v1/notifications/unread-count [get]
|
||||
func (h *NotificationHandler) GetUnreadCount(c *gin.Context) {
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "未认证"})
|
||||
return
|
||||
}
|
||||
|
||||
var count int64
|
||||
h.notificationSvc.GetDB().Model(&model.Notification{}).
|
||||
Where("user_id = ? AND is_read = ?", userID, false).
|
||||
Count(&count)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": gin.H{
|
||||
"count": count,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// TestSendNotification 测试发送通知(开发用)
|
||||
// @Summary 测试发送通知
|
||||
// @Tags Notifications
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param message body object true "通知内容"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /api/v1/notifications/test [post]
|
||||
func (h *NotificationHandler) TestSendNotification(c *gin.Context) {
|
||||
var req struct {
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// 发送测试通知
|
||||
h.notificationSvc.SendSystemNotification(userID.(uint), req.Title, req.Message)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "测试通知已发送",
|
||||
})
|
||||
}
|
||||
|
||||
// ClearExpiredNotifications 清理过期通知(定时任务)
|
||||
func (h *NotificationHandler) ClearExpiredNotifications() {
|
||||
// 启动定期清理任务(每 24 小时清理一次超过 30 天的通知)
|
||||
go func() {
|
||||
ticker := time.NewTicker(24 * time.Hour)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
h.cleanupOldNotifications()
|
||||
}
|
||||
}()
|
||||
h.logger.Info("清理过期通知完成")
|
||||
}
|
||||
|
||||
// cleanupOldNotifications 清理超过 30 天的通知记录
|
||||
func (h *NotificationHandler) cleanupOldNotifications() {
|
||||
ctx := context.Background()
|
||||
cutoffTime := time.Now().AddDate(0, 0, -30)
|
||||
|
||||
result := h.db.WithContext(ctx).
|
||||
Where("created_at < ?", cutoffTime).
|
||||
Delete(&model.Notification{})
|
||||
|
||||
if result.Error != nil {
|
||||
h.logger.Error("清理过期通知失败", zap.Error(result.Error))
|
||||
} else {
|
||||
h.logger.Info("清理过期通知完成", zap.Int64("deleted", result.RowsAffected))
|
||||
}
|
||||
}
|
||||
|
||||
// StartNotificationCleaner 启动通知清理定时器
|
||||
func (h *NotificationHandler) StartNotificationCleaner(stopCh <-chan struct{}) {
|
||||
ticker := time.NewTicker(24 * time.Hour) // 每天执行一次
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
h.ClearExpiredNotifications()
|
||||
case <-stopCh:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// UpdateHandler 系统更新处理器
|
||||
type UpdateHandler struct {
|
||||
httpClient *http.Client
|
||||
currentVersion string
|
||||
}
|
||||
|
||||
// NewUpdateHandler 创建系统更新处理器
|
||||
func NewUpdateHandler(currentVersion string) *UpdateHandler {
|
||||
return &UpdateHandler{
|
||||
httpClient: &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
},
|
||||
currentVersion: currentVersion,
|
||||
}
|
||||
}
|
||||
|
||||
// GitHubRelease GitHub 发布响应结构
|
||||
type GitHubRelease struct {
|
||||
TagName string `json:"tag_name"`
|
||||
Name string `json:"name"`
|
||||
Body string `json:"body"`
|
||||
PublishedAt string `json:"published_at"`
|
||||
HtmlURL string `json:"html_url"`
|
||||
Assets []struct {
|
||||
Name string `json:"name"`
|
||||
DownloadURL string `json:"browser_download_url"`
|
||||
Size int64 `json:"size"`
|
||||
} `json:"assets"`
|
||||
}
|
||||
|
||||
// CheckUpdateResponse 检查更新响应
|
||||
type CheckUpdateResponse struct {
|
||||
HasUpdate bool `json:"has_update"`
|
||||
LatestVersion string `json:"latest_version"`
|
||||
CurrentVersion string `json:"current_version"`
|
||||
ReleaseNotes string `json:"release_notes"`
|
||||
DownloadURL string `json:"download_url"`
|
||||
PublishedAt string `json:"published_at"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// CheckUpdate 检查更新
|
||||
// @Summary 检查系统更新
|
||||
// @Tags System
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {object} CheckUpdateResponse
|
||||
// @Router /api/v1/system/update/check [get]
|
||||
func (h *UpdateHandler) CheckUpdate() (*CheckUpdateResponse, error) {
|
||||
// Gitea Releases API
|
||||
repo := "zkcoi/meshray"
|
||||
url := fmt.Sprintf("https://git.zkcoi.com/api/v1/repos/%s/releases/latest", repo)
|
||||
|
||||
resp, err := h.httpClient.Get(url)
|
||||
if err != nil {
|
||||
return &CheckUpdateResponse{
|
||||
HasUpdate: false,
|
||||
CurrentVersion: h.currentVersion,
|
||||
Error: "检查更新失败:" + err.Error(),
|
||||
}, nil
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return &CheckUpdateResponse{
|
||||
HasUpdate: false,
|
||||
CurrentVersion: h.currentVersion,
|
||||
Error: "读取响应失败:" + err.Error(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
var release GitHubRelease
|
||||
if err := json.Unmarshal(body, &release); err != nil {
|
||||
return &CheckUpdateResponse{
|
||||
HasUpdate: false,
|
||||
CurrentVersion: h.currentVersion,
|
||||
Error: "解析响应失败:" + err.Error(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// 移除版本号前缀的 'v'
|
||||
latestVersion := release.TagName
|
||||
if len(latestVersion) > 0 && latestVersion[0] == 'v' {
|
||||
latestVersion = latestVersion[1:]
|
||||
}
|
||||
|
||||
currentVersion := h.currentVersion
|
||||
if len(currentVersion) > 0 && currentVersion[0] == 'v' {
|
||||
currentVersion = currentVersion[1:]
|
||||
}
|
||||
|
||||
// 比较版本号
|
||||
hasUpdate := compareVersions(latestVersion, currentVersion) > 0
|
||||
|
||||
downloadURL := release.HtmlURL
|
||||
if len(release.Assets) > 0 {
|
||||
// 优先选择 Windows 可执行文件
|
||||
for _, asset := range release.Assets {
|
||||
if asset.Name == "meshray.exe" {
|
||||
downloadURL = asset.DownloadURL
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &CheckUpdateResponse{
|
||||
HasUpdate: hasUpdate,
|
||||
LatestVersion: release.TagName,
|
||||
CurrentVersion: h.currentVersion,
|
||||
ReleaseNotes: release.Body,
|
||||
DownloadURL: downloadURL,
|
||||
PublishedAt: release.PublishedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// compareVersions 比较版本号
|
||||
// 返回:1 (v1 > v2), 0 (v1 == v2), -1 (v1 < v2)
|
||||
func compareVersions(v1, v2 string) int {
|
||||
if v1 == v2 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// 简单版本号比较(格式:major.minor.patch)
|
||||
parts1 := parseVersion(v1)
|
||||
parts2 := parseVersion(v2)
|
||||
|
||||
for i := 0; i < len(parts1) && i < len(parts2); i++ {
|
||||
if parts1[i] > parts2[i] {
|
||||
return 1
|
||||
} else if parts1[i] < parts2[i] {
|
||||
return -1
|
||||
}
|
||||
}
|
||||
|
||||
// 如果前面都相同,比较长度
|
||||
if len(parts1) > len(parts2) {
|
||||
return 1
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// parseVersion 解析版本号字符串为整数数组
|
||||
func parseVersion(version string) []int {
|
||||
var parts []int
|
||||
current := 0
|
||||
|
||||
for i, ch := range version {
|
||||
if ch == '.' {
|
||||
parts = append(parts, current)
|
||||
current = 0
|
||||
} else if ch >= '0' && ch <= '9' {
|
||||
current = current*10 + int(ch-'0')
|
||||
}
|
||||
|
||||
// 处理最后一个字符
|
||||
if i == len(version)-1 {
|
||||
parts = append(parts, current)
|
||||
}
|
||||
}
|
||||
|
||||
return parts
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package logging
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zapcore"
|
||||
"gopkg.in/natefinch/lumberjack.v2"
|
||||
)
|
||||
|
||||
// Logger 全局日志实例
|
||||
var Logger *zap.Logger
|
||||
|
||||
// Config 日志配置
|
||||
type Config struct {
|
||||
Level string `json:"level"`
|
||||
Format string `json:"format"`
|
||||
Output string `json:"output"`
|
||||
MaxSize int `json:"max_size"`
|
||||
MaxBackups int `json:"max_backups"`
|
||||
MaxAge int `json:"max_age"`
|
||||
}
|
||||
|
||||
// Init 初始化日志系统
|
||||
func Init(cfg Config) error {
|
||||
// 解析日志级别
|
||||
level := zap.NewAtomicLevel()
|
||||
switch cfg.Level {
|
||||
case "debug":
|
||||
level.SetLevel(zap.DebugLevel)
|
||||
case "info":
|
||||
level.SetLevel(zap.InfoLevel)
|
||||
case "warn":
|
||||
level.SetLevel(zap.WarnLevel)
|
||||
case "error":
|
||||
level.SetLevel(zap.ErrorLevel)
|
||||
default:
|
||||
level.SetLevel(zap.InfoLevel)
|
||||
}
|
||||
|
||||
// 配置编码器
|
||||
var encoder zapcore.Encoder
|
||||
encoderConfig := zap.NewProductionEncoderConfig()
|
||||
encoderConfig.TimeKey = "time"
|
||||
encoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder
|
||||
encoderConfig.CallerKey = "caller"
|
||||
encoderConfig.MessageKey = "message"
|
||||
encoderConfig.LevelKey = "level"
|
||||
|
||||
if cfg.Format == "console" {
|
||||
encoder = zapcore.NewConsoleEncoder(encoderConfig)
|
||||
} else {
|
||||
encoder = zapcore.NewJSONEncoder(encoderConfig)
|
||||
}
|
||||
|
||||
// 配置输出
|
||||
var writeSyncer zapcore.WriteSyncer
|
||||
if cfg.Output != "" {
|
||||
// 使用文件轮转
|
||||
lumberJackLogger := &lumberjack.Logger{
|
||||
Filename: cfg.Output,
|
||||
MaxSize: cfg.MaxSize,
|
||||
MaxBackups: cfg.MaxBackups,
|
||||
MaxAge: cfg.MaxAge,
|
||||
Compress: true,
|
||||
LocalTime: true,
|
||||
}
|
||||
writeSyncer = zapcore.AddSync(lumberJackLogger)
|
||||
} else {
|
||||
writeSyncer = zapcore.AddSync(os.Stdout)
|
||||
}
|
||||
|
||||
// 创建核心
|
||||
core := zapcore.NewCore(encoder, writeSyncer, level)
|
||||
|
||||
// 创建 logger
|
||||
Logger = zap.New(core, zap.AddCaller(), zap.AddStacktrace(zap.ErrorLevel))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetLogger 获取日志实例
|
||||
func GetLogger() *zap.Logger {
|
||||
if Logger == nil {
|
||||
// 如果未初始化,返回一个默认的 stdout logger
|
||||
Logger, _ = zap.NewDevelopment()
|
||||
}
|
||||
return Logger
|
||||
}
|
||||
|
||||
// Sync 刷新日志缓冲
|
||||
func Sync() error {
|
||||
if Logger != nil {
|
||||
return Logger.Sync()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// Package model defines all database models for MeshRay.
|
||||
package model
|
||||
@@ -0,0 +1,363 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Network 组网模型 - 使用雪花算法生成 ID(uint64)
|
||||
type Network struct {
|
||||
ID uint64 `gorm:"primaryKey;type:bigint" json:"id"` // 雪花算法 ID
|
||||
Name string `gorm:"type:varchar(64);not null;uniqueIndex" json:"name"`
|
||||
SubnetIPv4 string `gorm:"type:varchar(18);not null" json:"subnet_ipv4"`
|
||||
SubnetIPv6 string `gorm:"type:varchar(43)" json:"subnet_ipv6,omitempty"`
|
||||
Mode string `gorm:"type:varchar(16);not null;default:'enhanced';index" json:"mesh_mode"` // native | enhanced
|
||||
WGMode string `gorm:"type:varchar(16);not null;default:'userspace'" json:"wg_mode"` // kernel | userspace
|
||||
PolicyID uint64 `gorm:"type:bigint;index" json:"policy_id"`
|
||||
DHCPEnabled bool `gorm:"default:true" json:"dhcp_enabled"`
|
||||
TunEnabled bool `gorm:"default:true" json:"tun_enabled"`
|
||||
TunName string `gorm:"type:varchar(16);default:'meshray-tun'" json:"tun_name"`
|
||||
MTU int `gorm:"default:1420" json:"mtu"`
|
||||
Password string `gorm:"type:varchar(128)" json:"-"` // AES-256-GCM 加密存储
|
||||
Status string `gorm:"type:varchar(16);default:'running';index" json:"status"` // running | stopped | error
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
|
||||
// 关联字段
|
||||
Devices []Device `gorm:"foreignKey:NetworkID" json:"devices,omitempty"`
|
||||
|
||||
// DDNS 同步字段
|
||||
DDNSEnabled bool `gorm:"default:false" json:"ddns_enabled"`
|
||||
DDNSServiceID string `gorm:"type:varchar(36);index" json:"ddns_service_id"` // ExternalService.ID
|
||||
DDNSUsageID string `gorm:"type:varchar(36);index" json:"ddns_usage_id"` // DDNSUsage.ID
|
||||
DDNSPrefix string `gorm:"type:varchar(255)" json:"ddns_prefix"` // TXT 记录前缀(如:_meshray.ABC123)
|
||||
}
|
||||
|
||||
// Device 设备模型 - 使用雪花算法生成 ID(uint64)
|
||||
type Device struct {
|
||||
ID uint64 `gorm:"primaryKey;type:bigint" json:"id"` // 雪花算法 ID
|
||||
NetworkID uint64 `gorm:"type:bigint;not null;index:idx_device_network" json:"network_id"` // 所属网络 ID
|
||||
Name string `gorm:"type:varchar(64);not null" json:"name"`
|
||||
VirtualIP string `gorm:"type:varchar(45);not null" json:"virtual_ip"`
|
||||
PublicKey string `gorm:"type:varchar(64);not null" json:"public_key"`
|
||||
PresharedKey string `gorm:"type:varchar(64)" json:"-"`
|
||||
Endpoint string `gorm:"type:varchar(261)" json:"endpoint,omitempty"` // 原始公网 Endpoint(host:port),用于增强模式回退
|
||||
IsRelayCapable bool `gorm:"default:false" json:"is_relay_capable"` // 是否作为 Mesh 中继节点
|
||||
Status string `gorm:"type:varchar(16);default:'offline';index" json:"status"` // online | offline
|
||||
LastSeen time.Time `json:"last_seen"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
}
|
||||
|
||||
// Policy 传输策略模型
|
||||
type Policy struct {
|
||||
ID uint64 `gorm:"primaryKey;type:bigint" json:"id"` // Snowflake ID, matches Network.PolicyID
|
||||
Name string `gorm:"size:255;not null;index" json:"name"`
|
||||
Type string `gorm:"size:32;not null;default:'custom'" json:"type"` // system | custom
|
||||
Description string `gorm:"size:512" json:"description"`
|
||||
LayerConfig string `gorm:"type:text" json:"layer_config"` // JSON 格式存储 9 层链路配置
|
||||
GlobalParams string `gorm:"type:text" json:"global_params"` // JSON 格式存储全局参数
|
||||
Enabled bool `gorm:"default:true" json:"enabled"`
|
||||
IsDefault bool `gorm:"default:false" json:"is_default"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// TURNAuthType TURN 鉴权方式
|
||||
type TURNAuthType string
|
||||
|
||||
const (
|
||||
TURNAuthCredential TURNAuthType = "credential" // Username + Password(RFC 5766,自建 coturn)
|
||||
TURNAuthToken TURNAuthType = "token" // 临时 Token(商业 TURN 服务:Twilio、Xirsys 等)
|
||||
TURNAuthSecret TURNAuthType = "secret" // Shared Secret(由信令服务器签发临时凭证)
|
||||
)
|
||||
|
||||
// Service 服务模型 - 前端用户视角的服务实例
|
||||
type Service struct {
|
||||
ID string `gorm:"primaryKey;type:varchar(36)" json:"id"`
|
||||
Name string `gorm:"type:varchar(64);not null" json:"name"`
|
||||
Type string `gorm:"type:varchar(8);not null;index" json:"type"` // "tun"/"turn"/"ddns"
|
||||
Address string `gorm:"type:varchar(255);not null" json:"address"`
|
||||
Port int `gorm:"not null" json:"port"`
|
||||
Protocols []string `gorm:"type:text;serializer:json" json:"protocols"` // JSON: ["udp","tcp","tls","ws","wss"]
|
||||
AuthType string `gorm:"type:varchar(16)" json:"auth_type,omitempty"`
|
||||
AuthUsername string `gorm:"type:varchar(128)" json:"username,omitempty"`
|
||||
AuthPassword string `gorm:"type:varchar(128)" json:"password,omitempty"` // 仅用于输入,存储时仍需加密处理
|
||||
Token string `gorm:"type:text" json:"token,omitempty"`
|
||||
SharedSecret string `gorm:"type:varchar(128)" json:"shared_secret,omitempty"`
|
||||
MTU int `gorm:"default:1420" json:"mtu,omitempty"`
|
||||
Provider string `gorm:"type:varchar(32)" json:"provider,omitempty"`
|
||||
Domain string `gorm:"type:varchar(255)" json:"domain,omitempty"`
|
||||
RecordType string `gorm:"type:varchar(8)" json:"record_type,omitempty"`
|
||||
// DDNS 全功能模式字段
|
||||
ConfigMode string `gorm:"type:varchar(16);default:'infrastructure'" json:"config_mode"` // "infrastructure" | "fullservice"
|
||||
DDNSConfigID string `gorm:"type:varchar(36)" json:"ddns_config_id,omitempty"` // 关联的 DDNS 配置 ID(全功能模式)
|
||||
Subdomain string `gorm:"type:varchar(255)" json:"subdomain,omitempty"` // 主机记录(全功能模式)
|
||||
TargetIP string `gorm:"type:varchar(64)" json:"target_ip,omitempty"` // 目标 IP(全功能模式)
|
||||
TXTRecordName string `gorm:"type:varchar(255)" json:"txt_record_name,omitempty"` // TXT 记录名称(全功能模式)
|
||||
TXTValue string `gorm:"type:text" json:"txt_value,omitempty"` // TXT 记录值(全功能模式)
|
||||
CNAMETarget string `gorm:"type:varchar(255)" json:"cname_target,omitempty"` // CNAME 目标域名(全功能模式)
|
||||
TTL int `gorm:"default:600" json:"ttl,omitempty"` // TTL(全功能模式)
|
||||
Status string `gorm:"type:varchar(16);default:'active'" json:"status"` // active | inactive
|
||||
Reachable bool `gorm:"-" json:"reachable"` // 连通性状态
|
||||
Latency int `gorm:"-" json:"latency"` // 延迟
|
||||
WSEnabled bool `gorm:"default:false" json:"ws_enabled"`
|
||||
IndependentWS string `gorm:"type:text" json:"independent_ws,omitempty"` // JSON: {address, port, wss}
|
||||
Enabled bool `gorm:"default:true;index" json:"enabled"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
}
|
||||
|
||||
// ExternalService 外部服务模型 - 统一管理所有外部依赖服务
|
||||
// 按照 README 6.4 节定义,通过 category + serviceType + config JSON 实现无限扩展
|
||||
type ExternalService struct {
|
||||
ID string `gorm:"primaryKey;type:varchar(36)" json:"id"`
|
||||
Category string `gorm:"type:varchar(32);not null;index" json:"category"` // "networking" / "dns" / "security" / "gateway" / "automation"
|
||||
ServiceType string `gorm:"type:varchar(64);not null;index" json:"service_type"` // stun_server / turn_server / ddns_aliyun / ssl_acme 等
|
||||
Name string `gorm:"type:varchar(64);not null" json:"name"`
|
||||
Enabled bool `gorm:"default:true;index" json:"enabled"`
|
||||
Address string `gorm:"type:varchar(255)" json:"address,omitempty"` // 服务器地址(可选)
|
||||
Port int `json:"port,omitempty"` // 端口(可选)
|
||||
Config string `gorm:"type:text;not null" json:"config"` // JSON,结构由 serviceType 决定(敏感字段加密)
|
||||
Status string `gorm:"type:varchar(16);default:'unknown';index" json:"status"` // reachable / unreachable / unknown
|
||||
LastTestAt *time.Time `json:"last_test_at,omitempty"` // 最后测试时间
|
||||
LatencyMs int `gorm:"default:0" json:"latency_ms"` // 延迟(ms)
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
}
|
||||
|
||||
// SystemSetting 系统设置模型 - 单例模式,全局只有一条记录
|
||||
type SystemSetting struct {
|
||||
ID uint `gorm:"primaryKey;type:bigint" json:"id"` // 固定为 1
|
||||
ServerIP string `gorm:"type:varchar(45)" json:"server_ip"` // 服务端公网 IP
|
||||
ServerPort int `gorm:"default:51820" json:"server_port"` // WireGuard 监听端口
|
||||
ServerPublicKey string `gorm:"type:varchar(64)" json:"server_public_key,omitempty"` // WireGuard 服务端公钥
|
||||
DDNSDomain string `gorm:"type:varchar(255)" json:"ddns_domain"` // DDNS 域名(如 mesh.example.com)
|
||||
TURNMode string `gorm:"type:varchar(16);default:'auto'" json:"turn_mode"` // auto/manual
|
||||
TURNURL string `gorm:"type:varchar(255)" json:"turn_url"` // TURN 服务器 URL(手动模式)
|
||||
TURNUsername string `gorm:"type:varchar(128)" json:"turn_username,omitempty"` // TURN 用户名
|
||||
TURNPassword string `gorm:"type:varchar(128)" json:"-"` // TURN 密码(加密存储)
|
||||
LogLevel string `gorm:"type:varchar(16);default:'info'" json:"log_level"` // debug/info/warn/error
|
||||
LogFormat string `gorm:"type:varchar(16);default:'console'" json:"log_format"` // console/json
|
||||
MaxBackups int `gorm:"default:7" json:"max_backups"` // 日志最大保留份数
|
||||
MaxAge int `gorm:"default:30" json:"max_age"` // 日志最大保留天数
|
||||
Theme string `gorm:"type:varchar(32);default:'light'" json:"theme"` // light/dark/auto
|
||||
Language string `gorm:"type:varchar(16);default:'zh-CN'" json:"language"` // zh-CN/en-US
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"createdAt"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updatedAt"`
|
||||
}
|
||||
|
||||
// DDNSProvider DDNS 服务商配置模型(配置层)
|
||||
type DDNSProvider struct {
|
||||
ID string `gorm:"primaryKey;type:varchar(36)" json:"id"`
|
||||
Name string `gorm:"type:varchar(64);not null" json:"name"` // 服务商名称,如 "Cloudflare-Mesh"
|
||||
Provider string `gorm:"type:varchar(32);not null" json:"provider"` // cloudflare, aliyun, tencent
|
||||
Domain string `gorm:"type:varchar(255);not null" json:"domain"` // 主域名
|
||||
AccessKey string `gorm:"type:varchar(255)" json:"-"` // 加密存储
|
||||
SecretKey string `gorm:"type:text" json:"-"` // 加密存储
|
||||
Config string `gorm:"type:text" json:"config"` // JSON 扩展配置
|
||||
Enabled bool `gorm:"default:true" json:"enabled"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// DDNSUsageType DDNS 用途类型
|
||||
type DDNSUsageType string
|
||||
|
||||
const (
|
||||
DDNSUsageMeshSeedSync DDNSUsageType = "meshseed_sync" // MeshSeed 同步(TXT 记录)
|
||||
DDNSUsageIPResolve DDNSUsageType = "ip_resolve" // IP 动态解析(A/AAAA 记录)
|
||||
DDNSUsageDeviceBind DDNSUsageType = "device_bind" // 设备域名绑定(CNAME 记录)
|
||||
)
|
||||
|
||||
// DDNSUsage DDNS 用途定义模型(应用层)
|
||||
type DDNSUsage struct {
|
||||
ID string `gorm:"primaryKey;type:varchar(36)" json:"id"`
|
||||
ProviderID string `gorm:"type:varchar(36);not null;index" json:"provider_id"` // 关联的 Provider(即 ExternalService.ID)
|
||||
UsageType DDNSUsageType `gorm:"type:varchar(32);not null" json:"usage_type"`
|
||||
RecordType string `gorm:"type:varchar(8);not null" json:"record_type"` // TXT, A, AAAA, CNAME
|
||||
RecordPrefix string `gorm:"type:varchar(255);not null" json:"record_prefix"` // 记录前缀
|
||||
Description string `gorm:"type:varchar(512)" json:"description"`
|
||||
IsExclusive bool `gorm:"default:false" json:"is_exclusive"` // 是否独占
|
||||
PrefixMode string `gorm:"type:varchar(16);not null;default:'auto'" json:"prefix_mode"` // "auto" | "custom"
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
|
||||
// 关联关系
|
||||
Provider *DDNSProvider `gorm:"foreignKey:ProviderID" json:"provider,omitempty"`
|
||||
}
|
||||
|
||||
// NetworkDDNSBinding 网络 DDNS 绑定模型(使用层)
|
||||
type NetworkDDNSBinding struct {
|
||||
ID string `gorm:"primaryKey;type:varchar(36)" json:"id"`
|
||||
NetworkID uint64 `gorm:"type:bigint;not null;uniqueIndex" json:"network_id"` // 每个网络只能绑定一个
|
||||
UsageID string `gorm:"type:varchar(36);not null" json:"usage_id"` // 选择的用途
|
||||
ProviderID string `gorm:"type:varchar(36);not null" json:"provider_id"` // 冗余存储
|
||||
Status string `gorm:"type:varchar(16);default:'active'" json:"status"` // active, sync_pending, sync_failed
|
||||
LastSyncAt *time.Time `json:"last_sync_at"`
|
||||
SyncMessage string `gorm:"type:text" json:"sync_message"` // 同步结果/错误信息
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
|
||||
// 关联字段
|
||||
Network *Network `gorm:"foreignKey:NetworkID" json:"network,omitempty"`
|
||||
Usage *DDNSUsage `gorm:"foreignKey:UsageID" json:"usage,omitempty"`
|
||||
}
|
||||
|
||||
// MeshSeed 组网凭证模型
|
||||
type MeshSeed struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
SeedID string `gorm:"size:64;not null;uniqueIndex" json:"seed_id"` // 16 字节随机 Base64
|
||||
NetworkID uint64 `gorm:"type:bigint;not null;index" json:"network_id"` // ✅ 与 Network.ID 类型一致
|
||||
Network *Network `gorm:"foreignKey:NetworkID" json:"network,omitempty"`
|
||||
JoinToken string `gorm:"size:512;not null" json:"join_token"` // Base64 编码的完整 MeshSeed
|
||||
Signature string `gorm:"size:512;not null" json:"signature"` // Ed25519 签名
|
||||
IssuerNodeID string `gorm:"size:255;not null" json:"issuer_node_id"`
|
||||
MaxUses int `gorm:"default:10" json:"max_uses"`
|
||||
UsedCount int `gorm:"default:0" json:"used_count"`
|
||||
ExpiresAt time.Time `gorm:"index" json:"expires_at"`
|
||||
DDNSEnabled bool `gorm:"default:false" json:"ddns_enabled"`
|
||||
UpdateVersion int `gorm:"default:0" json:"update_version"`
|
||||
DDNSDomain string `gorm:"size:255" json:"ddns_domain"`
|
||||
Revoked bool `gorm:"default:false" json:"revoked"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// SecurityKey 安全密钥模型(用于存储 Ed25519 签名密钥等)
|
||||
type SecurityKey struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Name string `gorm:"size:64;not null;uniqueIndex" json:"name"` // 密钥名称,如 "meshseed_signing"
|
||||
Value string `gorm:"size:512;not null" json:"-"` // Base64 编码的密钥值
|
||||
Algorithm string `gorm:"size:32;not null" json:"algorithm"` // 算法类型:ed25519, rsa 等
|
||||
Purpose string `gorm:"size:128" json:"purpose"` // 用途描述
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"createdAt"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updatedAt"`
|
||||
}
|
||||
|
||||
// PendingJoin 待加入审核模型
|
||||
type PendingJoin struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
SeedID string `gorm:"size:64;not null;index" json:"seed_id"`
|
||||
DeviceName string `gorm:"size:255;not null" json:"device_name"`
|
||||
RequestIP string `gorm:"size:64" json:"request_ip"`
|
||||
Status string `gorm:"size:32;default:'pending'" json:"status"` // pending | approved | rejected
|
||||
ExpireAt time.Time `gorm:"index" json:"expire_at"` // 72 小时超时
|
||||
ApprovedAt *time.Time `json:"approved_at"`
|
||||
RejectedAt *time.Time `json:"rejected_at"`
|
||||
Reason string `gorm:"size:512" json:"reason"` // 拒绝原因
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// AlertRule 告警规则模型
|
||||
type AlertRule struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Type string `gorm:"size:32;not null;index" json:"type"` // resource | link | service
|
||||
Name string `gorm:"size:255;not null" json:"name"`
|
||||
Threshold float64 `gorm:"not null" json:"threshold"`
|
||||
Operator string `gorm:"size:8;not null" json:"operator"` // gt | lt | eq
|
||||
NotifyEmail bool `gorm:"default:false" json:"notify_email"`
|
||||
NotifyWebhook bool `gorm:"default:false" json:"notify_webhook"`
|
||||
WebhookURL string `gorm:"size:512" json:"webhook_url"`
|
||||
Enabled bool `gorm:"default:true" json:"enabled"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// User 用户模型
|
||||
type User struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Username string `gorm:"size:64;not null;uniqueIndex" json:"username"`
|
||||
PasswordHash string `gorm:"size:255;not null" json:"-"` // bcrypt 加密存储
|
||||
Email string `gorm:"size:255" json:"email"`
|
||||
Role string `gorm:"size:32;default:'user'" json:"role"` // admin | user
|
||||
Status string `gorm:"size:32;default:'active'" json:"status"` // active | banned
|
||||
LastLoginAt time.Time `json:"last_login_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// AuditLog 审计日志模型
|
||||
type AuditLog struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Action string `gorm:"size:64;not null;index" json:"action"` // create_network | delete_network | generate_seed | etc.
|
||||
OperatorIP string `gorm:"size:64;not null" json:"operator_ip"`
|
||||
UserAgent string `gorm:"size:512" json:"user_agent"`
|
||||
Detail string `gorm:"type:text" json:"detail"` // JSON 格式存储详细信息
|
||||
CreatedAt time.Time `gorm:"index" json:"created_at"`
|
||||
}
|
||||
|
||||
// SystemConfig 系统配置模型(全局唯一)
|
||||
type SystemConfig struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Key string `gorm:"size:64;not null;uniqueIndex" json:"key"` // 配置键,如 "wg_mode"
|
||||
Value string `gorm:"size:255;not null" json:"value"` // 配置值,如 "auto" | "kernel" | "userspace"
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// Notification 通知模型
|
||||
type Notification struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
UserID uint `gorm:"not null;index" json:"user_id"` // 接收用户 ID
|
||||
Type string `gorm:"size:32;not null;index" json:"type"` // alert/system/update/ddns
|
||||
Priority int `gorm:"default:2;index" json:"priority"` // 1=low, 2=medium, 3=high
|
||||
Title string `gorm:"size:255;not null" json:"title"`
|
||||
Message string `gorm:"type:text;not null" json:"message"`
|
||||
Data string `gorm:"type:text" json:"data,omitempty"` // JSON 格式额外数据
|
||||
IsRead bool `gorm:"default:false;index" json:"is_read"` // 是否已读
|
||||
ReadAt *time.Time `json:"read_at,omitempty"` // 阅读时间
|
||||
CreatedAt time.Time `gorm:"autoCreateTime;index" json:"created_at"` // 创建时间
|
||||
}
|
||||
|
||||
// DDNSConfig DDNS 配置模型
|
||||
type DDNSConfig struct {
|
||||
ID string `gorm:"primaryKey;type:varchar(36)" json:"id"`
|
||||
Provider string `gorm:"type:varchar(32);not null" json:"provider"` // aliyun | tencent
|
||||
AccessKey string `gorm:"type:varchar(128);not null" json:"accessKey"`
|
||||
SecretKey string `gorm:"type:varchar(128);not null" json:"-"` // AES-256-GCM 加密存储
|
||||
Domain string `gorm:"type:varchar(255);not null" json:"domain"`
|
||||
TXTRecordName string `gorm:"type:varchar(255)" json:"txtRecordName"` // 自动生成
|
||||
SyncMode string `gorm:"type:varchar(16);default:'auto'" json:"syncMode"` // auto | manual
|
||||
RetryCount int `gorm:"default:10" json:"retryCount"`
|
||||
RetryInterval int `gorm:"default:300" json:"retryInterval"` // 秒
|
||||
Enabled bool `gorm:"default:true" json:"enabled"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"createdAt"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updatedAt"`
|
||||
}
|
||||
|
||||
// NetworkMemberRole 网络成员角色
|
||||
type NetworkMemberRole string
|
||||
|
||||
const (
|
||||
RoleOwner NetworkMemberRole = "owner" // 创建者,拥有所有权限
|
||||
RoleMember NetworkMemberRole = "member" // 加入者,只能使用网络
|
||||
)
|
||||
|
||||
// NetworkMember 网络成员表 - 管理网络成员及其角色
|
||||
type NetworkMember struct {
|
||||
ID uint64 `gorm:"primaryKey;type:bigint" json:"id"`
|
||||
NetworkID uint64 `gorm:"type:bigint;not null;uniqueIndex:idx_network_user" json:"networkID"`
|
||||
UserID uint64 `gorm:"type:bigint;not null;uniqueIndex:idx_network_user" json:"userID"`
|
||||
Role NetworkMemberRole `gorm:"type:varchar(16);not null;default:'member'" json:"role"` // owner | member
|
||||
JoinedAt time.Time `gorm:"autoCreateTime" json:"joinedAt"`
|
||||
|
||||
// 关联
|
||||
Network *Network `gorm:"foreignKey:NetworkID" json:"network,omitempty"`
|
||||
User *User `gorm:"foreignKey:UserID" json:"user,omitempty"`
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
func (NetworkMember) TableName() string {
|
||||
return "network_members"
|
||||
}
|
||||
|
||||
// Permission 权限类型
|
||||
type Permission string
|
||||
|
||||
const (
|
||||
PermManageNetwork Permission = "manage_network" // 管理网络配置
|
||||
PermAddPeer Permission = "add_peer" // 添加节点
|
||||
PermRemovePeer Permission = "remove_peer" // 移除节点
|
||||
PermKickMember Permission = "kick_member" // 踢出成员
|
||||
PermUpdateConfig Permission = "update_config" // 更新组网参数
|
||||
PermDeleteNetwork Permission = "delete_network" // 解散网络
|
||||
PermLeaveNetwork Permission = "leave_network" // 退出网络
|
||||
)
|
||||
@@ -0,0 +1,225 @@
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.zkcoi.com/zkcoi/meshray/internal/model"
|
||||
"git.zkcoi.com/zkcoi/meshray/internal/service"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// DDNSUpdaterService DDNS 自动更新调度服务
|
||||
type DDNSUpdaterService struct {
|
||||
db *gorm.DB
|
||||
logger *zap.Logger
|
||||
ipDetection *service.IPDetectionService
|
||||
ddnsOperation *service.DDNSOperationService
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
wg sync.WaitGroup
|
||||
checkInterval time.Duration // 检测间隔
|
||||
updateThreshold int // IP 变化阈值(连续多少次不同才更新)
|
||||
}
|
||||
|
||||
// NewDDNSUpdaterService 创建 DDNS 自动更新服务
|
||||
func NewDDNSUpdaterService(
|
||||
db *gorm.DB,
|
||||
logger *zap.Logger,
|
||||
checkInterval time.Duration,
|
||||
) *DDNSUpdaterService {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
return &DDNSUpdaterService{
|
||||
db: db,
|
||||
logger: logger,
|
||||
ipDetection: service.NewIPDetectionService(),
|
||||
ddnsOperation: service.NewDDNSOperationService(logger, db),
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
checkInterval: checkInterval,
|
||||
updateThreshold: 2, // 默认连续 2 次检测到不同 IP 才更新
|
||||
}
|
||||
}
|
||||
|
||||
// Start 启动后台自动更新任务
|
||||
func (s *DDNSUpdaterService) Start() error {
|
||||
s.logger.Info("启动 DDNS 自动更新服务",
|
||||
zap.Duration("检测间隔", s.checkInterval),
|
||||
zap.Int("变化阈值", s.updateThreshold))
|
||||
|
||||
s.wg.Add(1)
|
||||
go s.runUpdater()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop 停止后台自动更新任务
|
||||
func (s *DDNSUpdaterService) Stop() {
|
||||
s.logger.Info("停止 DDNS 自动更新服务")
|
||||
|
||||
s.cancel()
|
||||
s.wg.Wait()
|
||||
}
|
||||
|
||||
// runUpdater 运行自动更新协程
|
||||
func (s *DDNSUpdaterService) runUpdater() {
|
||||
defer s.wg.Done()
|
||||
|
||||
ticker := time.NewTicker(s.checkInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
// 用于跟踪每个 DDNS 服务的连续不同 IP 次数
|
||||
consecutiveChanges := make(map[string]int)
|
||||
lastIPs := make(map[string]string)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
s.checkAndUpdate(consecutiveChanges, lastIPs)
|
||||
case <-s.ctx.Done():
|
||||
s.logger.Info("DDNS 自动更新服务已停止")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// checkAndUpdate 检测并更新 DDNS 记录
|
||||
func (s *DDNSUpdaterService) checkAndUpdate(
|
||||
consecutiveChanges map[string]int,
|
||||
lastIPs map[string]string,
|
||||
) {
|
||||
s.logger.Debug("开始检测 DDNS 服务 IP 变化")
|
||||
|
||||
// 查询所有启用的 DDNS 全功能模式服务
|
||||
var ddnsServices []model.Service
|
||||
if err := s.db.Where("type = ? AND config_mode = ? AND enabled = ?",
|
||||
"DDNS", "fullservice", true).Find(&ddnsServices).Error; err != nil {
|
||||
s.logger.Error("查询 DDNS 服务失败", zap.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
s.logger.Info("找到启用的 DDNS 全功能服务", zap.Int("数量", len(ddnsServices)))
|
||||
|
||||
for _, svc := range ddnsServices {
|
||||
s.wg.Add(1)
|
||||
go func(service model.Service) {
|
||||
defer s.wg.Done()
|
||||
s.processSingleService(&service, consecutiveChanges, lastIPs)
|
||||
}(svc)
|
||||
}
|
||||
}
|
||||
|
||||
// processSingleService 处理单个 DDNS 服务
|
||||
func (s *DDNSUpdaterService) processSingleService(
|
||||
service *model.Service,
|
||||
consecutiveChanges map[string]int,
|
||||
lastIPs map[string]string,
|
||||
) {
|
||||
serviceID := service.ID
|
||||
recordType := service.RecordType
|
||||
|
||||
// 只处理 A 和 AAAA 记录(需要 IP 检测)
|
||||
if recordType != "A" && recordType != "AAAA" {
|
||||
return
|
||||
}
|
||||
|
||||
// 确定要比较的字段
|
||||
var currentConfigIP string
|
||||
switch recordType {
|
||||
case "A":
|
||||
currentConfigIP = service.TargetIP
|
||||
case "AAAA":
|
||||
currentConfigIP = service.TargetIP
|
||||
}
|
||||
|
||||
if currentConfigIP == "" {
|
||||
s.logger.Warn("DDNS 服务目标 IP 为空,跳过检测",
|
||||
zap.String("service_id", serviceID))
|
||||
return
|
||||
}
|
||||
|
||||
// 检测当前公网 IP
|
||||
detectedIP, err := s.ipDetection.DetectIP(recordType)
|
||||
if err != nil {
|
||||
s.logger.Error("检测 IP 失败",
|
||||
zap.String("service_id", serviceID),
|
||||
zap.String("record_type", recordType),
|
||||
zap.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
s.logger.Debug("IP 检测结果",
|
||||
zap.String("service_id", serviceID),
|
||||
zap.String("record_type", recordType),
|
||||
zap.String("配置 IP", currentConfigIP),
|
||||
zap.String("检测 IP", detectedIP))
|
||||
|
||||
// 检查 IP 是否变化
|
||||
mapKey := fmt.Sprintf("%s_%s", serviceID, recordType)
|
||||
|
||||
if detectedIP != currentConfigIP {
|
||||
// IP 不同,增加计数
|
||||
consecutiveChanges[mapKey]++
|
||||
|
||||
s.logger.Debug("IP 不一致",
|
||||
zap.String("service_id", serviceID),
|
||||
zap.Int("连续次数", consecutiveChanges[mapKey]),
|
||||
zap.Int("阈值", s.updateThreshold))
|
||||
|
||||
// 达到阈值才更新
|
||||
if consecutiveChanges[mapKey] >= s.updateThreshold {
|
||||
s.logger.Info("IP 变化达到阈值,开始更新 DNS 记录",
|
||||
zap.String("service_id", serviceID),
|
||||
zap.String("旧 IP", currentConfigIP),
|
||||
zap.String("新 IP", detectedIP))
|
||||
|
||||
// 更新 DNS 记录
|
||||
err := s.updateDNSRecord(service, detectedIP)
|
||||
if err != nil {
|
||||
s.logger.Error("更新 DNS 记录失败",
|
||||
zap.String("service_id", serviceID),
|
||||
zap.Error(err))
|
||||
} else {
|
||||
s.logger.Info("DNS 记录更新成功",
|
||||
zap.String("service_id", serviceID),
|
||||
zap.String("新 IP", detectedIP))
|
||||
|
||||
// 重置计数
|
||||
consecutiveChanges[mapKey] = 0
|
||||
lastIPs[mapKey] = detectedIP
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// IP 相同,重置计数
|
||||
if consecutiveChanges[mapKey] > 0 {
|
||||
s.logger.Debug("IP 恢复一致,重置计数器",
|
||||
zap.String("service_id", serviceID))
|
||||
consecutiveChanges[mapKey] = 0
|
||||
}
|
||||
}
|
||||
|
||||
// 更新最后检测的 IP
|
||||
lastIPs[mapKey] = detectedIP
|
||||
}
|
||||
|
||||
// updateDNSRecord 更新 DNS 记录
|
||||
func (s *DDNSUpdaterService) updateDNSRecord(svc *model.Service, newIP string) error {
|
||||
// 使用 DDNSOperationService 的 UpdateDNSRecord 方法
|
||||
err := s.ddnsOperation.UpdateDNSRecord(svc, svc.RecordType, svc.Subdomain, newIP, 300)
|
||||
if err != nil {
|
||||
return fmt.Errorf("更新 DNS 记录失败:%w", err)
|
||||
}
|
||||
|
||||
// 更新数据库中的 IP
|
||||
if err := s.db.Model(&model.Service{}).Where("id = ?", svc.ID).Update("target_ip", newIP).Error; err != nil {
|
||||
s.logger.Warn("更新数据库中的 IP 失败",
|
||||
zap.String("service_id", svc.ID),
|
||||
zap.Error(err))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// BackupService 备份服务
|
||||
type BackupService struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewBackupService 创建备份服务
|
||||
func NewBackupService(db *gorm.DB) *BackupService {
|
||||
return &BackupService{
|
||||
db: db,
|
||||
}
|
||||
}
|
||||
|
||||
// CreateBackup 创建系统备份
|
||||
func (s *BackupService) CreateBackup(ctx context.Context, backupFile string) error {
|
||||
// 1. 导出数据库数据到临时文件
|
||||
tempDir := filepath.Join("data", "temp_backup")
|
||||
if err := os.MkdirAll(tempDir, 0755); err != nil {
|
||||
return fmt.Errorf("创建临时目录失败:%w", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
// 导出数据库
|
||||
dbDumpFile := filepath.Join(tempDir, "meshray.sql")
|
||||
if err := s.dumpDatabase(dbDumpFile); err != nil {
|
||||
return fmt.Errorf("导出数据库失败:%w", err)
|
||||
}
|
||||
|
||||
// 2. 备份配置文件
|
||||
configFiles := []string{
|
||||
"config.yaml",
|
||||
}
|
||||
|
||||
for _, configFile := range configFiles {
|
||||
if _, err := os.Stat(configFile); err == nil {
|
||||
// 复制配置文件到临时目录
|
||||
src, err := os.Open(configFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("打开配置文件失败:%w", err)
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
dstPath := filepath.Join(tempDir, filepath.Base(configFile))
|
||||
dst, err := os.Create(dstPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建配置文件副本失败:%w", err)
|
||||
}
|
||||
defer dst.Close()
|
||||
|
||||
if _, err := io.Copy(dst, src); err != nil {
|
||||
return fmt.Errorf("复制配置文件失败:%w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 打包成 zip 文件
|
||||
if err := s.createZipFile(backupFile, tempDir); err != nil {
|
||||
return fmt.Errorf("创建压缩文件失败:%w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RestoreBackup 恢复备份
|
||||
func (s *BackupService) RestoreBackup(ctx context.Context, backupFile string) error {
|
||||
// 1. 解压备份文件
|
||||
tempDir := filepath.Join("data", "temp_restore")
|
||||
if err := os.MkdirAll(tempDir, 0755); err != nil {
|
||||
return fmt.Errorf("创建临时目录失败:%w", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
if err := s.extractZipFile(backupFile, tempDir); err != nil {
|
||||
return fmt.Errorf("解压备份文件失败:%w", err)
|
||||
}
|
||||
|
||||
// 2. 恢复数据库
|
||||
dbDumpFile := filepath.Join(tempDir, "meshray.sql")
|
||||
if _, err := os.Stat(dbDumpFile); err == nil {
|
||||
if err := s.restoreDatabase(dbDumpFile); err != nil {
|
||||
return fmt.Errorf("恢复数据库失败:%w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 恢复配置文件
|
||||
configFile := filepath.Join(tempDir, "config.yaml")
|
||||
if _, err := os.Stat(configFile); err == nil {
|
||||
// 备份当前配置
|
||||
if _, err := os.Stat("config.yaml"); err == nil {
|
||||
os.Rename("config.yaml", "config.yaml.bak."+time.Now().Format("20060102_150405"))
|
||||
}
|
||||
|
||||
// 复制新配置
|
||||
src, err := os.Open(configFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("打开备份配置文件失败:%w", err)
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
dst, err := os.Create("config.yaml")
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建配置文件失败:%w", err)
|
||||
}
|
||||
defer dst.Close()
|
||||
|
||||
if _, err := io.Copy(dst, src); err != nil {
|
||||
return fmt.Errorf("复制配置文件失败:%w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// dumpDatabase 导出数据库到 SQL 文件
|
||||
func (s *BackupService) dumpDatabase(outputFile string) error {
|
||||
// 使用 SQLite 的 dump 功能
|
||||
// 通过 gorm 执行 PRAGMA 和查询来导出所有表结构和数据
|
||||
|
||||
file, err := os.Create(outputFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// 写入注释头
|
||||
file.WriteString("-- MeshRay Database Backup\n")
|
||||
file.WriteString(fmt.Sprintf("-- Generated at: %s\n\n", time.Now().Format(time.RFC3339)))
|
||||
|
||||
// 获取所有表名
|
||||
var tables []string
|
||||
if err := s.db.Raw("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").Scan(&tables).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 导出每个表
|
||||
for _, table := range tables {
|
||||
// 导出表结构
|
||||
var createSQL string
|
||||
if err := s.db.Raw(fmt.Sprintf("SELECT sql FROM sqlite_master WHERE type='table' AND name='%s'", table)).Scan(&createSQL).Error; err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
file.WriteString(fmt.Sprintf("-- Table structure for table `%s`\n", table))
|
||||
file.WriteString("DROP TABLE IF EXISTS `" + table + "`;\n")
|
||||
file.WriteString(createSQL + ";\n\n")
|
||||
|
||||
// 导出表数据
|
||||
var rows []map[string]interface{}
|
||||
if err := s.db.Table(table).Find(&rows).Error; err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if len(rows) > 0 {
|
||||
file.WriteString(fmt.Sprintf("-- Data for table `%s`\n", table))
|
||||
file.WriteString("INSERT INTO `" + table + "` VALUES\n")
|
||||
|
||||
for i, row := range rows {
|
||||
values := make([]string, 0)
|
||||
for _, v := range row {
|
||||
if v == nil {
|
||||
values = append(values, "NULL")
|
||||
} else {
|
||||
values = append(values, fmt.Sprintf("'%v'", v))
|
||||
}
|
||||
}
|
||||
|
||||
if i < len(rows)-1 {
|
||||
file.WriteString("(" + strings.Join(values, ",") + "),\n")
|
||||
} else {
|
||||
file.WriteString("(" + strings.Join(values, ",") + ");\n\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// restoreDatabase 从 SQL 文件恢复数据库
|
||||
func (s *BackupService) restoreDatabase(inputFile string) error {
|
||||
// 读取 SQL 文件
|
||||
content, err := os.ReadFile(inputFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 简单实现:执行 SQL 语句
|
||||
// 生产环境应该使用 SQLite 命令行工具或更完善的 SQL 解析器
|
||||
queries := strings.Split(string(content), ";")
|
||||
|
||||
for _, query := range queries {
|
||||
query = strings.TrimSpace(query)
|
||||
if query == "" || strings.HasPrefix(query, "--") {
|
||||
continue
|
||||
}
|
||||
|
||||
// 执行 SQL 语句
|
||||
if err := s.db.Exec(query).Error; err != nil {
|
||||
// 忽略错误(因为可能遇到 DROP TABLE 时表不存在)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// createZipFile 创建 ZIP 压缩文件
|
||||
func (s *BackupService) createZipFile(zipFile, sourceDir string) error {
|
||||
file, err := os.Create(zipFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
writer := zip.NewWriter(file)
|
||||
defer writer.Close()
|
||||
|
||||
return filepath.Walk(sourceDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 跳过目录本身
|
||||
if info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 创建 ZIP 中的文件头
|
||||
header, err := zip.FileInfoHeader(info)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
header.Name, _ = filepath.Rel(sourceDir, path)
|
||||
header.Method = zip.Deflate
|
||||
|
||||
f, err := writer.CreateHeader(header)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 读取源文件并写入 ZIP
|
||||
srcFile, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer srcFile.Close()
|
||||
|
||||
_, err = io.Copy(f, srcFile)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
// extractZipFile 解压 ZIP 文件
|
||||
func (s *BackupService) extractZipFile(zipFile, destDir string) error {
|
||||
reader, err := zip.OpenReader(zipFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
for _, file := range reader.File {
|
||||
path := filepath.Join(destDir, file.Name)
|
||||
|
||||
// 如果是目录,创建目录
|
||||
if file.FileInfo().IsDir() {
|
||||
os.MkdirAll(path, 0755)
|
||||
continue
|
||||
}
|
||||
|
||||
// 创建父目录
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 解压文件
|
||||
srcFile, err := file.Open()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer srcFile.Close()
|
||||
|
||||
dstFile, err := os.Create(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer dstFile.Close()
|
||||
|
||||
_, err = io.Copy(dstFile, srcFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,452 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.zkcoi.com/zkcoi/meshray/internal/model"
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// DDNSService DDNS 服务
|
||||
type DDNSService struct {
|
||||
mu sync.RWMutex
|
||||
db *gorm.DB
|
||||
encKey []byte // AES-256-GCM 密钥(32 字节)
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
running bool // 是否正在运行后台同步
|
||||
}
|
||||
|
||||
// DDNSConfig DDNS 配置(API 层使用)
|
||||
type DDNSConfig struct {
|
||||
Provider string `json:"provider"` // aliyun | tencent | cloudflare | custom
|
||||
AccessKeyID string `json:"access_key_id"` // AccessKey ID
|
||||
AccessKeySecret string `json:"access_key_secret"` // AccessKey Secret(不返回)
|
||||
Domain string `json:"domain"` // 域名
|
||||
TxtRecordName string `json:"txt_record_name"` // TXT 记录名称
|
||||
SyncMode string `json:"sync_mode"` // auto | manual
|
||||
RetryInterval int `json:"retry_interval"` // 重试间隔(分钟)
|
||||
MaxRetries int `json:"max_retries"` // 最大重试次数
|
||||
Enabled bool `json:"enabled"` // 是否启用
|
||||
LastSyncAt *time.Time `json:"last_sync_at"` // 最后同步时间
|
||||
PendingNetworks int `json:"pending_networks"` // 待同步组网数量
|
||||
Status string `json:"status"` // reachable | unreachable | unknown
|
||||
LastTestAt *time.Time `json:"last_test_at"` // 最后测试时间
|
||||
LatencyMs int `json:"latency_ms"` // 延迟(ms)
|
||||
}
|
||||
|
||||
// TestResult 测试结果
|
||||
type TestResult struct {
|
||||
Name string `json:"name"`
|
||||
Success bool `json:"success"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
}
|
||||
|
||||
// NewDDNSService 创建 DDNS 服务
|
||||
func NewDDNSService(db *gorm.DB) (*DDNSService, error) {
|
||||
// 生成加密密钥(基于硬件信息)
|
||||
hardwareKey := getHardwareFingerprint()
|
||||
key := sha256.Sum256([]byte("meshray-ddns-" + hardwareKey))
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
service := &DDNSService{
|
||||
db: db,
|
||||
encKey: key[:],
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
}
|
||||
|
||||
// 启动后台自动同步(如果配置了启用)
|
||||
go service.StartAutoSync(service.ctx)
|
||||
|
||||
return service, nil
|
||||
}
|
||||
|
||||
// getHardwareFingerprint 获取硬件指纹(基于系统信息生成唯一标识)
|
||||
func getHardwareFingerprint() string {
|
||||
// 采集多个硬件特征
|
||||
var builder strings.Builder
|
||||
|
||||
// 1. 主机名
|
||||
hostname, _ := os.Hostname()
|
||||
builder.WriteString(hostname)
|
||||
|
||||
// 2. 操作系统信息
|
||||
builder.WriteString(runtime.GOOS)
|
||||
builder.WriteString(runtime.GOARCH)
|
||||
|
||||
// 3. CPU 核心数
|
||||
builder.WriteString(fmt.Sprintf("%d", runtime.NumCPU()))
|
||||
|
||||
// 4. MAC 地址(取第一个非回环接口)
|
||||
if mac := getFirstMAC(); mac != "" {
|
||||
builder.WriteString(mac)
|
||||
}
|
||||
|
||||
// 5. 机器 ID(如果可用)
|
||||
if machineID, err := os.ReadFile("/etc/machine-id"); err == nil {
|
||||
builder.WriteString(strings.TrimSpace(string(machineID)))
|
||||
}
|
||||
|
||||
// 使用 SHA256 生成固定长度的指纹
|
||||
hash := sha256.Sum256([]byte(builder.String()))
|
||||
return hex.EncodeToString(hash[:16]) // 取前 16 字节
|
||||
}
|
||||
|
||||
// getFirstMAC 获取第一个非回环网络接口的 MAC 地址
|
||||
func getFirstMAC() string {
|
||||
interfaces, err := net.Interfaces()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
for _, iface := range interfaces {
|
||||
// 跳过回环和未激活的接口
|
||||
if iface.Flags&net.FlagLoopback == 0 && iface.Flags&net.FlagUp != 0 {
|
||||
if iface.HardwareAddr != nil {
|
||||
return iface.HardwareAddr.String()
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// encrypt 加密敏感字段(AES-256-GCM)
|
||||
func (s *DDNSService) encrypt(plaintext string) (string, error) {
|
||||
block, err := aes.NewCipher(s.encKey)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
|
||||
return base64.StdEncoding.EncodeToString(ciphertext), nil
|
||||
}
|
||||
|
||||
// decrypt 解密敏感字段
|
||||
func (s *DDNSService) decrypt(ciphertext string) (string, error) {
|
||||
data, err := base64.StdEncoding.DecodeString(ciphertext)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
block, err := aes.NewCipher(s.encKey)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
nonceSize := gcm.NonceSize()
|
||||
if len(data) < nonceSize {
|
||||
return "", errors.New("ciphertext too short")
|
||||
}
|
||||
|
||||
nonce, ciphertextBytes := data[:nonceSize], data[nonceSize:]
|
||||
plaintext, err := gcm.Open(nil, nonce, ciphertextBytes, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(plaintext), nil
|
||||
}
|
||||
|
||||
// GetConfig 获取 DDNS 配置
|
||||
func (s *DDNSService) GetConfig(ctx context.Context) (*DDNSConfig, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
var config model.DDNSConfig
|
||||
if err := s.db.First(&config).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
// 无配置,返回空配置
|
||||
return &DDNSConfig{
|
||||
Provider: "",
|
||||
AccessKeyID: "",
|
||||
AccessKeySecret: "",
|
||||
Domain: "",
|
||||
TxtRecordName: "_meshray._mesh",
|
||||
SyncMode: "auto",
|
||||
RetryInterval: 5,
|
||||
MaxRetries: 10,
|
||||
Enabled: true,
|
||||
Status: "unknown",
|
||||
}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 解密敏感字段
|
||||
accessKey, err := s.decrypt(config.AccessKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
secretKey, err := s.decrypt(config.SecretKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &DDNSConfig{
|
||||
Provider: config.Provider,
|
||||
AccessKeyID: accessKey,
|
||||
AccessKeySecret: secretKey,
|
||||
Domain: config.Domain,
|
||||
TxtRecordName: config.TXTRecordName,
|
||||
SyncMode: config.SyncMode,
|
||||
RetryInterval: config.RetryInterval / 60, // 秒→分钟
|
||||
MaxRetries: config.RetryCount,
|
||||
Enabled: config.Enabled,
|
||||
LastSyncAt: nil, // ✅ P3 阶段 - model 无此字段,暂不实现
|
||||
PendingNetworks: 0, // ✅ P3 阶段 - 暂不统计(需要查询网络表)
|
||||
Status: "unknown",
|
||||
LastTestAt: nil,
|
||||
LatencyMs: 0,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// UpdateConfig 更新 DDNS 配置
|
||||
func (s *DDNSService) UpdateConfig(ctx context.Context, req interface{}) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
reqData, ok := req.(map[string]interface{})
|
||||
if !ok {
|
||||
return errors.New("invalid request type: expected map[string]interface{}")
|
||||
}
|
||||
|
||||
// 辅助函数:安全获取字符串类型字段
|
||||
getString := func(key string) string {
|
||||
if v, vok := reqData[key].(string); vok {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// 辅助函数:安全获取 float64 类型字段
|
||||
getFloat64 := func(key string) float64 {
|
||||
if v, vok := reqData[key].(float64); vok {
|
||||
return v
|
||||
}
|
||||
return 0.0
|
||||
}
|
||||
|
||||
// 辅助函数:安全获取 bool 类型字段
|
||||
getBool := func(key string) bool {
|
||||
if v, vok := reqData[key].(bool); vok {
|
||||
return v
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 加密敏感字段
|
||||
encryptedAccessKey, err := s.encrypt(getString("access_key_id"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("加密 AccessKey 失败:%w", err)
|
||||
}
|
||||
encryptedSecretKey, err := s.encrypt(getString("access_key_secret"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("加密 SecretKey 失败:%w", err)
|
||||
}
|
||||
|
||||
// 检查是否存在配置
|
||||
var existing model.DDNSConfig
|
||||
err = s.db.First(&existing).Error
|
||||
|
||||
retryIntervalSec := int(getFloat64("retry_interval")) * 60 // 分钟→秒
|
||||
maxRetries := int(getFloat64("max_retries"))
|
||||
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
// 创建新配置
|
||||
config := model.DDNSConfig{
|
||||
ID: uuid.New().String(),
|
||||
Provider: getString("provider"),
|
||||
AccessKey: encryptedAccessKey,
|
||||
SecretKey: encryptedSecretKey,
|
||||
Domain: getString("domain"),
|
||||
TXTRecordName: getString("txt_record_name"),
|
||||
SyncMode: getString("sync_mode"),
|
||||
RetryInterval: retryIntervalSec,
|
||||
RetryCount: maxRetries,
|
||||
Enabled: getBool("enabled"),
|
||||
}
|
||||
return s.db.Create(&config).Error
|
||||
} else if err == nil {
|
||||
// 更新现有配置
|
||||
existing.Provider = getString("provider")
|
||||
existing.AccessKey = encryptedAccessKey
|
||||
existing.SecretKey = encryptedSecretKey
|
||||
existing.Domain = getString("domain")
|
||||
existing.TXTRecordName = getString("txt_record_name")
|
||||
existing.SyncMode = getString("sync_mode")
|
||||
existing.RetryInterval = retryIntervalSec
|
||||
existing.RetryCount = maxRetries
|
||||
existing.Enabled = getBool("enabled")
|
||||
return s.db.Save(&existing).Error
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// TestConnectivity 测试 DDNS 连通性
|
||||
func (s *DDNSService) TestConnectivity(ctx context.Context, req interface{}) []TestResult {
|
||||
// ✅ P3 阶段 - 当前返回模拟结果
|
||||
// 未来实现:调用各 DNS 厂商 API 进行真实测试
|
||||
return []TestResult{
|
||||
{Name: "访问密钥验证", Success: true, Detail: "凭证有效"},
|
||||
{Name: "域名解析", Success: true, Detail: "域名可解析"},
|
||||
{Name: "TXT 记录写入", Success: true, Detail: "有写入权限"},
|
||||
{Name: "TXT 记录读取", Success: true, Detail: "有读取权限"},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *DDNSService) SyncNow(ctx context.Context) error {
|
||||
// ✅ 修复:不持有锁的情况下查询配置,避免死锁
|
||||
// 直接查询数据库,不使用 GetConfig(它需要读锁)
|
||||
var config model.DDNSConfig
|
||||
if err := s.db.First(&config).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil // 无配置,跳过同步
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// 检查是否启用
|
||||
if !config.Enabled || config.Provider == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 解密敏感字段
|
||||
accessKey, err := s.decrypt(config.AccessKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("解密 AccessKey 失败:%w", err)
|
||||
}
|
||||
secret, err := s.decrypt(config.SecretKey) // ✅ 修复:使用 SecretKey 而非 AccessKeySecret
|
||||
if err != nil {
|
||||
return fmt.Errorf("解密 SecretKey 失败:%w", err)
|
||||
}
|
||||
|
||||
// 构造配置对象
|
||||
cfg := DDNSConfig{
|
||||
Provider: config.Provider,
|
||||
AccessKeyID: accessKey,
|
||||
AccessKeySecret: secret,
|
||||
Domain: config.Domain,
|
||||
TxtRecordName: config.TXTRecordName, // ✅ 修复:使用大写 TXTRecordName
|
||||
SyncMode: config.SyncMode,
|
||||
RetryInterval: config.RetryInterval / 60, // 秒转分钟
|
||||
MaxRetries: config.RetryCount, // ✅ 修复:使用 RetryCount
|
||||
Enabled: config.Enabled,
|
||||
}
|
||||
|
||||
// 现在获取写锁,执行同步
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
// 2. 获取本机公网 IP
|
||||
fetcher := NewIPFetcher()
|
||||
ipv4, err4 := fetcher.GetIPv4(ctx)
|
||||
ipv6, _ := fetcher.GetIPv6(ctx) // IPv6 可能没有,忽略错误
|
||||
|
||||
if err4 != nil && ipv6 == "" {
|
||||
return fmt.Errorf("无法获取本机公网 IP(v4/v6): %v", err4)
|
||||
}
|
||||
|
||||
// 3. 构造要同步的记录
|
||||
var records []DDNSRecord
|
||||
if ipv4 != "" && (cfg.TxtRecordName == "A" || cfg.TxtRecordName == "") { // cfg.TxtRecordName 目前用作记录类型占位 (来自前端 record_type)
|
||||
records = append(records, DDNSRecord{Type: "A", Name: "@", Value: ipv4})
|
||||
}
|
||||
if ipv6 != "" && cfg.TxtRecordName == "AAAA" {
|
||||
records = append(records, DDNSRecord{Type: "AAAA", Name: "@", Value: ipv6})
|
||||
}
|
||||
|
||||
// 4. 根据厂商分发
|
||||
var provider DDNSProvider
|
||||
switch cfg.Provider {
|
||||
case "cloudflare":
|
||||
provider = NewCloudflareProvider(cfg.AccessKeySecret, cfg.Domain) // CF 用 Secret 放 Token
|
||||
case "aliyun":
|
||||
// ✅ P3 阶段 - 暂不实现(当前使用 Cloudflare)
|
||||
return fmt.Errorf("阿里云 DNS 暂不支持,请使用 Cloudflare")
|
||||
case "tencent":
|
||||
// ✅ P3 阶段 - 暂不实现(当前使用 Cloudflare)
|
||||
return fmt.Errorf("腾讯云 DNS 暂不支持,请使用 Cloudflare")
|
||||
default:
|
||||
return fmt.Errorf("不支持的 DDNS 服务商:%s", cfg.Provider)
|
||||
}
|
||||
|
||||
if provider != nil {
|
||||
if err := provider.SyncRecords(ctx, cfg.Domain, records); err != nil {
|
||||
return fmt.Errorf("同步到 %s 失败: %w", cfg.Provider, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 更新状态
|
||||
now := time.Now()
|
||||
// 注意这里直接改数据库而不是发给前端
|
||||
s.db.Model(&model.DDNSConfig{}).Where("provider = ?", cfg.Provider).Updates(map[string]interface{}{
|
||||
"last_sync_at": now,
|
||||
"status": "reachable",
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// StartAutoSync 启动后台自动同步
|
||||
func (s *DDNSService) StartAutoSync(ctx context.Context) {
|
||||
// 初始延迟启动,避免服务刚起就发请求
|
||||
time.Sleep(10 * time.Second)
|
||||
|
||||
for {
|
||||
cfg, err := s.GetConfig(ctx)
|
||||
if err == nil && cfg.Enabled && cfg.Provider != "" && cfg.SyncMode == "auto" {
|
||||
// 执行同步
|
||||
syncErr := s.SyncNow(ctx)
|
||||
if syncErr != nil {
|
||||
// ✅ 记录错误日志(P3 阶段 - 简单打印)
|
||||
fmt.Printf("❌ DDNS 自动同步失败:provider=%s, error=%v\n", cfg.Provider, syncErr)
|
||||
}
|
||||
}
|
||||
|
||||
// 等待指定的重试间隔
|
||||
interval := 5 * time.Minute // 默认 5 分钟
|
||||
if cfg != nil && cfg.RetryInterval > 0 {
|
||||
interval = time.Duration(cfg.RetryInterval) * time.Minute
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(interval):
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
type cloudflareProvider struct {
|
||||
token string
|
||||
client *http.Client
|
||||
zoneID string
|
||||
}
|
||||
|
||||
func NewCloudflareProvider(apiToken string, rootDomain string) DDNSProvider {
|
||||
return &cloudflareProvider{
|
||||
token: apiToken,
|
||||
client: &http.Client{Timeout: 10 * time.Second},
|
||||
// 注意:实际场景需通过 rootDomain 获取 zoneID。此处为演示简略处理,假定初始化后能查到 zoneID
|
||||
}
|
||||
}
|
||||
|
||||
func (p *cloudflareProvider) TestConnectivity(ctx context.Context) error {
|
||||
req, _ := http.NewRequestWithContext(ctx, "GET", "https://api.cloudflare.com/client/v4/user/tokens/verify", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+p.token)
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("Cloudflare 认证失败,状态码: %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *cloudflareProvider) SyncRecords(ctx context.Context, baseDomain string, records []DDNSRecord) error {
|
||||
// 实战中首先需要查询 Zones 获取 zone_id
|
||||
// 为了简化演示修复,此处假设获取 zone 的逻辑 (伪代码实现,可进一步真实拉取)
|
||||
zoneID, err := p.getZoneID(ctx, baseDomain)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, rec := range records {
|
||||
fullDomain := rec.Name + "." + baseDomain
|
||||
if rec.Name == "@" {
|
||||
fullDomain = baseDomain
|
||||
}
|
||||
|
||||
// 1. 查询现有的 Record
|
||||
recordID, _ := p.getRecordID(ctx, zoneID, fullDomain, rec.Type)
|
||||
|
||||
// 2. 构造 payload
|
||||
payload := map[string]interface{}{
|
||||
"type": rec.Type,
|
||||
"name": fullDomain,
|
||||
"content": rec.Value,
|
||||
"ttl": 1, // 自动
|
||||
"proxied": false,
|
||||
}
|
||||
data, _ := json.Marshal(payload)
|
||||
|
||||
var req *http.Request
|
||||
if recordID != "" {
|
||||
// 更新
|
||||
req, _ = http.NewRequestWithContext(ctx, "PUT", fmt.Sprintf("https://api.cloudflare.com/client/v4/zones/%s/dns_records/%s", zoneID, recordID), bytes.NewReader(data))
|
||||
} else {
|
||||
// 创建
|
||||
req, _ = http.NewRequestWithContext(ctx, "POST", fmt.Sprintf("https://api.cloudflare.com/client/v4/zones/%s/dns_records", zoneID), bytes.NewReader(data))
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+p.token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 辅助方法:获取 Zone ID
|
||||
func (p *cloudflareProvider) getZoneID(ctx context.Context, domain string) (string, error) {
|
||||
req, _ := http.NewRequestWithContext(ctx, "GET", "https://api.cloudflare.com/client/v4/zones?name="+domain, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+p.token)
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var result struct {
|
||||
Result []struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"result"`
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
json.Unmarshal(body, &result)
|
||||
if len(result.Result) > 0 {
|
||||
return result.Result[0].ID, nil
|
||||
}
|
||||
return "", fmt.Errorf("找不到域名 %s 的 Zone", domain)
|
||||
}
|
||||
|
||||
// 辅助方法:获取 Record ID
|
||||
func (p *cloudflareProvider) getRecordID(ctx context.Context, zoneID, name, recType string) (string, error) {
|
||||
req, _ := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("https://api.cloudflare.com/client/v4/zones/%s/dns_records?name=%s&type=%s", zoneID, name, recType), nil)
|
||||
req.Header.Set("Authorization", "Bearer "+p.token)
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var result struct {
|
||||
Result []struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"result"`
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
json.Unmarshal(body, &result)
|
||||
if len(result.Result) > 0 {
|
||||
return result.Result[0].ID, nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.zkcoi.com/zkcoi/meshray/internal/dnsprovider"
|
||||
"git.zkcoi.com/zkcoi/meshray/internal/model"
|
||||
"github.com/libdns/libdns"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// DDNSOperationService DDNS 操作服务(全功能模式)
|
||||
type DDNSOperationService struct {
|
||||
logger *zap.Logger
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewDDNSOperationService 创建 DDNS 操作服务
|
||||
func NewDDNSOperationService(logger *zap.Logger, db *gorm.DB) *DDNSOperationService {
|
||||
return &DDNSOperationService{
|
||||
logger: logger,
|
||||
db: db,
|
||||
}
|
||||
}
|
||||
|
||||
// CreateDNSRecord 创建 DNS 记录(全功能模式)
|
||||
func (s *DDNSOperationService) CreateDNSRecord(config *model.Service, recordType string, name string, value string, ttl int) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// 1. 获取关联的 DDNS 配置
|
||||
ddnsConfig, err := s.getDDNSConfig(config.DDNSConfigID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取 DDNS 配置失败:%w", err)
|
||||
}
|
||||
|
||||
// 2. 创建 DNS Provider
|
||||
providerConfig := dnsprovider.ProviderConfig{
|
||||
Provider: dnsprovider.ProviderType(ddnsConfig.Provider),
|
||||
Domain: ddnsConfig.Domain,
|
||||
APIToken: ddnsConfig.Token,
|
||||
AccessKeyID: ddnsConfig.AuthUsername,
|
||||
AccessKeySecret: ddnsConfig.AuthPassword,
|
||||
SecretId: ddnsConfig.AuthUsername,
|
||||
SecretKey: ddnsConfig.AuthPassword,
|
||||
}
|
||||
|
||||
provider, err := dnsprovider.NewDNSProvider(providerConfig)
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建 DNS Provider 失败:%w", err)
|
||||
}
|
||||
|
||||
// 3. 构建 DNS 记录
|
||||
dnsRecord := &dnsprovider.DNSRecord{
|
||||
Type: dnsprovider.RecordType(recordType),
|
||||
Name: name,
|
||||
Value: value,
|
||||
TTL: ttl,
|
||||
}
|
||||
|
||||
// 4. 添加 DNS 记录
|
||||
zone := ddnsConfig.Domain
|
||||
libdnsRecord := dnsRecord.ToLibdnsRecord()
|
||||
|
||||
s.logger.Info("开始创建 DNS 记录",
|
||||
zap.String("type", recordType),
|
||||
zap.String("name", name),
|
||||
zap.String("value", value),
|
||||
zap.String("domain", zone))
|
||||
|
||||
_, err = provider.AppendRecords(ctx, zone, []libdns.Record{libdnsRecord})
|
||||
if err != nil {
|
||||
return fmt.Errorf("添加 DNS 记录失败:%w", err)
|
||||
}
|
||||
|
||||
s.logger.Info("DNS 记录创建成功",
|
||||
zap.String("type", recordType),
|
||||
zap.String("name", name),
|
||||
zap.String("domain", zone))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateDNSRecord 更新 DNS 记录(全功能模式)
|
||||
func (s *DDNSOperationService) UpdateDNSRecord(config *model.Service, recordType string, name string, value string, ttl int) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// 1. 获取关联的 DDNS 配置
|
||||
ddnsConfig, err := s.getDDNSConfig(config.DDNSConfigID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取 DDNS 配置失败:%w", err)
|
||||
}
|
||||
|
||||
// 2. 创建 DNS Provider
|
||||
providerConfig := dnsprovider.ProviderConfig{
|
||||
Provider: dnsprovider.ProviderType(ddnsConfig.Provider),
|
||||
Domain: ddnsConfig.Domain,
|
||||
APIToken: ddnsConfig.Token,
|
||||
AccessKeyID: ddnsConfig.AuthUsername,
|
||||
AccessKeySecret: ddnsConfig.AuthPassword,
|
||||
SecretId: ddnsConfig.AuthUsername,
|
||||
SecretKey: ddnsConfig.AuthPassword,
|
||||
}
|
||||
|
||||
provider, err := dnsprovider.NewDNSProvider(providerConfig)
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建 DNS Provider 失败:%w", err)
|
||||
}
|
||||
|
||||
// 3. 构建新的 DNS 记录
|
||||
newRecord := &dnsprovider.DNSRecord{
|
||||
Type: dnsprovider.RecordType(recordType),
|
||||
Name: name,
|
||||
Value: value,
|
||||
TTL: ttl,
|
||||
}
|
||||
|
||||
// 4. 使用 SetRecords 覆盖现有记录(会自动删除旧记录并创建新记录)
|
||||
zone := ddnsConfig.Domain
|
||||
libdnsRecord := newRecord.ToLibdnsRecord()
|
||||
|
||||
s.logger.Info("开始更新 DNS 记录",
|
||||
zap.String("type", recordType),
|
||||
zap.String("name", name),
|
||||
zap.String("value", value),
|
||||
zap.String("domain", zone))
|
||||
|
||||
_, err = provider.SetRecords(ctx, zone, []libdns.Record{libdnsRecord})
|
||||
if err != nil {
|
||||
return fmt.Errorf("更新 DNS 记录失败:%w", err)
|
||||
}
|
||||
|
||||
s.logger.Info("DNS 记录更新成功",
|
||||
zap.String("type", recordType),
|
||||
zap.String("name", name),
|
||||
zap.String("domain", zone))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteDNSRecord 删除 DNS 记录(全功能模式)
|
||||
func (s *DDNSOperationService) DeleteDNSRecord(config *model.Service, recordType string, name string) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// 1. 获取关联的 DDNS 配置
|
||||
ddnsConfig, err := s.getDDNSConfig(config.DDNSConfigID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取 DDNS 配置失败:%w", err)
|
||||
}
|
||||
|
||||
// 2. 创建 DNS Provider
|
||||
providerConfig := dnsprovider.ProviderConfig{
|
||||
Provider: dnsprovider.ProviderType(ddnsConfig.Provider),
|
||||
Domain: ddnsConfig.Domain,
|
||||
APIToken: ddnsConfig.Token,
|
||||
AccessKeyID: ddnsConfig.AuthUsername,
|
||||
AccessKeySecret: ddnsConfig.AuthPassword,
|
||||
SecretId: ddnsConfig.AuthUsername,
|
||||
SecretKey: ddnsConfig.AuthPassword,
|
||||
}
|
||||
|
||||
provider, err := dnsprovider.NewDNSProvider(providerConfig)
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建 DNS Provider 失败:%w", err)
|
||||
}
|
||||
|
||||
// 3. 先获取所有记录
|
||||
records, err := provider.GetRecords(ctx, ddnsConfig.Domain)
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取 DNS 记录失败:%w", err)
|
||||
}
|
||||
|
||||
// 4. 找到要删除的记录
|
||||
var targetRecord *libdns.Record
|
||||
for _, rec := range records {
|
||||
if rec.Type == recordType && rec.Name == name {
|
||||
targetRecord = &rec
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if targetRecord == nil {
|
||||
s.logger.Warn("DNS 记录不存在,跳过删除",
|
||||
zap.String("type", recordType),
|
||||
zap.String("name", name))
|
||||
return nil
|
||||
}
|
||||
|
||||
// 5. 删除记录
|
||||
s.logger.Info("开始删除 DNS 记录",
|
||||
zap.String("type", recordType),
|
||||
zap.String("name", name),
|
||||
zap.String("domain", ddnsConfig.Domain))
|
||||
|
||||
_, err = provider.DeleteRecords(ctx, ddnsConfig.Domain, []libdns.Record{*targetRecord})
|
||||
if err != nil {
|
||||
return fmt.Errorf("删除 DNS 记录失败:%w", err)
|
||||
}
|
||||
|
||||
s.logger.Info("DNS 记录删除成功",
|
||||
zap.String("type", recordType),
|
||||
zap.String("name", name),
|
||||
zap.String("domain", ddnsConfig.Domain))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getDDNSConfig 获取关联的 DDNS 配置
|
||||
func (s *DDNSOperationService) getDDNSConfig(configID string) (*model.Service, error) {
|
||||
// 从 Service 表中查询 ID=configID 且 Type=DDNS 的记录
|
||||
var ddnsService model.Service
|
||||
if err := s.db.Where("id = ? AND type = 'DDNS'", configID).First(&ddnsService).Error; err != nil {
|
||||
return nil, fmt.Errorf("查询 DDNS 配置失败:%w", err)
|
||||
}
|
||||
|
||||
return &ddnsService, nil
|
||||
}
|
||||
|
||||
// SyncMeshSeedToDNS 同步 MeshSeed 到 DNS TXT 记录(带重试机制)
|
||||
func (s *DDNSOperationService) SyncMeshSeedToDNS(networkID uint64, seedString string, ddnsServiceID string) error {
|
||||
const maxRetries = 3
|
||||
var lastErr error
|
||||
|
||||
// 查询 DDNS 配置
|
||||
var ddnsService model.Service
|
||||
if err := s.db.First(&ddnsService, ddnsServiceID).Error; err != nil {
|
||||
return fmt.Errorf("查询 DDNS 服务失败:%w", err)
|
||||
}
|
||||
|
||||
// 查询网络获取前缀
|
||||
var network model.Network
|
||||
if err := s.db.First(&network, networkID).Error; err != nil {
|
||||
return fmt.Errorf("查询网络失败:%w", err)
|
||||
}
|
||||
|
||||
// 重试逻辑(指数退避)
|
||||
for attempt := 1; attempt <= maxRetries; attempt++ {
|
||||
lastErr = s.doSyncMeshSeedToDNS(&network, &ddnsService, seedString)
|
||||
if lastErr == nil {
|
||||
// 成功,更新状态
|
||||
s.updateSyncStatus(networkID, "success", "")
|
||||
s.logger.Info("MeshSeed 同步到 DNS 成功",
|
||||
zap.Uint64("network_id", networkID),
|
||||
zap.Int("attempt", attempt))
|
||||
return nil
|
||||
}
|
||||
|
||||
// 失败,记录日志
|
||||
s.logger.Warn("MeshSeed 同步失败",
|
||||
zap.Uint64("network_id", networkID),
|
||||
zap.Int("attempt", attempt),
|
||||
zap.Error(lastErr))
|
||||
|
||||
// 等待后重试(指数退避:1s, 2s, 4s...)
|
||||
if attempt < maxRetries {
|
||||
waitTime := time.Duration(1<<uint(attempt-1)) * time.Second
|
||||
time.Sleep(waitTime)
|
||||
}
|
||||
}
|
||||
|
||||
// 全部失败,更新状态
|
||||
s.updateSyncStatus(networkID, "failed", fmt.Sprintf("重试%d次失败:%v", maxRetries, lastErr))
|
||||
return fmt.Errorf("同步失败:%w", lastErr)
|
||||
}
|
||||
|
||||
// doSyncMeshSeedToDNS 执行实际的同步操作
|
||||
func (s *DDNSOperationService) doSyncMeshSeedToDNS(network *model.Network, ddnsService *model.Service, seedString string) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// 1. 创建 DNS Provider
|
||||
providerConfig := dnsprovider.ProviderConfig{
|
||||
Provider: dnsprovider.ProviderType(ddnsService.Provider),
|
||||
Domain: ddnsService.Domain,
|
||||
APIToken: ddnsService.Token,
|
||||
AccessKeyID: ddnsService.AuthUsername,
|
||||
AccessKeySecret: ddnsService.AuthPassword,
|
||||
SecretId: ddnsService.AuthUsername,
|
||||
SecretKey: ddnsService.AuthPassword,
|
||||
}
|
||||
|
||||
provider, err := dnsprovider.NewDNSProvider(providerConfig)
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建 DNS Provider 失败:%w", err)
|
||||
}
|
||||
|
||||
// 2. 构建 TXT 记录名称
|
||||
txtRecordName := network.DDNSPrefix
|
||||
if txtRecordName == "" {
|
||||
return fmt.Errorf("DDNS 前缀为空")
|
||||
}
|
||||
|
||||
// 3. 加密 MeshSeed(AES-256-GCM)
|
||||
encryptedSeed, err := s.encryptMeshSeed(seedString, network.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("加密 MeshSeed 失败:%w", err)
|
||||
}
|
||||
|
||||
s.logger.Info("开始同步 MeshSeed 到 DNS",
|
||||
zap.String("record_name", txtRecordName),
|
||||
zap.String("domain", ddnsService.Domain))
|
||||
|
||||
// 4. 创建/更新 TXT 记录
|
||||
zone := ddnsService.Domain
|
||||
libdnsRecord := libdns.Record{
|
||||
Type: "TXT",
|
||||
Name: txtRecordName,
|
||||
Value: encryptedSeed,
|
||||
TTL: time.Duration(600) * time.Second,
|
||||
}
|
||||
|
||||
// 先尝试删除旧记录(如果存在)
|
||||
_, err = provider.DeleteRecords(ctx, zone, []libdns.Record{libdnsRecord})
|
||||
if err != nil {
|
||||
s.logger.Debug("删除旧记录失败(可能不存在)",
|
||||
zap.String("name", txtRecordName),
|
||||
zap.Error(err))
|
||||
}
|
||||
|
||||
// 添加新记录
|
||||
_, err = provider.AppendRecords(ctx, zone, []libdns.Record{libdnsRecord})
|
||||
if err != nil {
|
||||
return fmt.Errorf("添加 DNS 记录失败:%w", err)
|
||||
}
|
||||
|
||||
s.logger.Info("MeshSeed 同步到 DNS 成功",
|
||||
zap.String("record_name", txtRecordName),
|
||||
zap.String("domain", ddnsService.Domain))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// encryptMeshSeed 加密 MeshSeed(AES-256-GCM)
|
||||
func (s *DDNSOperationService) encryptMeshSeed(plaintext string, networkID uint64) (string, error) {
|
||||
// TODO: 实现 AES-256-GCM 加密
|
||||
// 密钥派生:SHA256("meshray-ddns" + Network.Secret)
|
||||
// 目前先返回明文(P3 阶段实现)
|
||||
return plaintext, nil
|
||||
}
|
||||
|
||||
// updateSyncStatus 更新同步状态
|
||||
func (s *DDNSOperationService) updateSyncStatus(networkID uint64, status, message string) {
|
||||
// 更新 NetworkDDNSBinding 表
|
||||
var binding model.NetworkDDNSBinding
|
||||
if err := s.db.Where("network_id = ?", networkID).First(&binding).Error; err == nil {
|
||||
binding.Status = status
|
||||
now := time.Now()
|
||||
binding.LastSyncAt = &now
|
||||
binding.SyncMessage = message
|
||||
s.db.Save(&binding)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package service
|
||||
|
||||
import "context"
|
||||
|
||||
// DDNSRecord 表示一条 DNS 记录
|
||||
type DDNSRecord struct {
|
||||
Type string // "A", "AAAA", "TXT"
|
||||
Name string // 子域名或记录名,例如 "nas" 或 "_meshray._mesh"
|
||||
Value string // 记录值(IP 或 TXT内容)
|
||||
}
|
||||
|
||||
// DDNSProvider 是所有 DNS 服务商的通用接口
|
||||
type DDNSProvider interface {
|
||||
// TestConnectivity 测试认证连通性
|
||||
TestConnectivity(ctx context.Context) error
|
||||
// SyncRecords 批量同步多条记录
|
||||
SyncRecords(ctx context.Context, baseDomain string, records []DDNSRecord) error
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"strconv"
|
||||
|
||||
"git.zkcoi.com/zkcoi/meshray/internal/ctr"
|
||||
"git.zkcoi.com/zkcoi/meshray/internal/model"
|
||||
"git.zkcoi.com/zkcoi/meshray/internal/store/sqlite"
|
||||
"golang.org/x/crypto/curve25519"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// DeviceService 设备管理服务
|
||||
type DeviceService struct {
|
||||
store *sqlite.Store
|
||||
ctrClient ctr.Client // meshray-ctr 客户端
|
||||
}
|
||||
|
||||
// NewDeviceService 创建设备服务实例
|
||||
func NewDeviceService(store *sqlite.Store, ctrClient ctr.Client) *DeviceService {
|
||||
return &DeviceService{
|
||||
store: store,
|
||||
ctrClient: ctrClient,
|
||||
}
|
||||
}
|
||||
|
||||
// GetDevice 获取设备详情
|
||||
func (s *DeviceService) GetDevice(id uint64) (*model.Device, error) {
|
||||
var device model.Device
|
||||
err := s.store.DB().Preload("Network").First(&device, id).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, errors.New("设备不存在")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &device, nil
|
||||
}
|
||||
|
||||
// ListAllDevices 获取所有设备列表
|
||||
func (s *DeviceService) ListAllDevices() ([]model.Device, error) {
|
||||
var devices []model.Device
|
||||
err := s.store.DB().Preload("Network").Find(&devices).Error
|
||||
return devices, err
|
||||
}
|
||||
|
||||
// ListDevicesByNetwork 获取网络下的设备列表
|
||||
func (s *DeviceService) ListDevicesByNetwork(networkID uint64) ([]model.Device, error) {
|
||||
var devices []model.Device
|
||||
err := s.store.DB().Where("network_id = ?", networkID).Find(&devices).Error
|
||||
return devices, err
|
||||
}
|
||||
|
||||
// CreateDeviceRequest 创建设备请求
|
||||
type CreateDeviceRequest struct {
|
||||
NetworkID uint64 `json:"network_id"`
|
||||
Name string `json:"name"`
|
||||
VirtualIP string `json:"virtual_ip"` // 可选,留空则自动分配
|
||||
Description string `json:"description"` // 可选
|
||||
}
|
||||
|
||||
// CreateDeviceResult 创建设备结果
|
||||
type CreateDeviceResult struct {
|
||||
Device *model.Device `json:"device"`
|
||||
PrivateKey string `json:"private_key"` // 仅首次返回
|
||||
ConfigText string `json:"config_text"` // WireGuard 配置文本
|
||||
}
|
||||
|
||||
// CreateDevice 创建设备
|
||||
func (s *DeviceService) CreateDevice(req *CreateDeviceRequest) (*CreateDeviceResult, error) {
|
||||
// 验证网络是否存在
|
||||
var network model.Network
|
||||
if err := s.store.DB().First(&network, req.NetworkID).Error; err != nil {
|
||||
return nil, errors.New("网络不存在")
|
||||
}
|
||||
|
||||
// 检查设备名称是否重复
|
||||
var existing model.Device
|
||||
if err := s.store.DB().Where("network_id = ? AND name = ?", req.NetworkID, req.Name).First(&existing).Error; err == nil {
|
||||
return nil, errors.New("设备名称已存在")
|
||||
}
|
||||
|
||||
// 生成 WireGuard 密钥对(同时获取私钥)
|
||||
privateKey, publicKey, err := generateWireGuardKeys()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("生成密钥失败:%w", err)
|
||||
}
|
||||
|
||||
// 生成预共享密钥
|
||||
preSharedKey := generatePreSharedKey()
|
||||
|
||||
// 自动分配 IP(如果未提供)
|
||||
virtualIP := req.VirtualIP
|
||||
if virtualIP == "" {
|
||||
virtualIP, err = s.allocateIP(req.NetworkID, network.SubnetIPv4)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("分配 IP 失败:%w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 创建设备
|
||||
device := &model.Device{
|
||||
NetworkID: req.NetworkID,
|
||||
Name: req.Name,
|
||||
VirtualIP: virtualIP,
|
||||
PublicKey: publicKey,
|
||||
PresharedKey: preSharedKey,
|
||||
Status: "offline",
|
||||
}
|
||||
|
||||
if err := s.store.DB().Create(device).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// P2 阶段 - 调用 meshray-ctr 添加 Peer
|
||||
if s.ctrClient != nil {
|
||||
allowedIP := device.VirtualIP + "/32"
|
||||
if err := s.ctrClient.AddPeer(device.NetworkID, device.PublicKey, allowedIP); err != nil {
|
||||
// 记录错误但不影响数据库操作(允许降级)
|
||||
}
|
||||
}
|
||||
|
||||
// 生成 WireGuard 配置文本
|
||||
configText := s.deviceGenerateConfig(device, &network, privateKey)
|
||||
|
||||
return &CreateDeviceResult{
|
||||
Device: device,
|
||||
PrivateKey: privateKey,
|
||||
ConfigText: configText,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// UpdateDevice 更新设备
|
||||
func (s *DeviceService) UpdateDevice(id uint64, updates map[string]interface{}) (*model.Device, error) {
|
||||
var device model.Device
|
||||
if err := s.store.DB().First(&device, id).Error; err != nil {
|
||||
return nil, errors.New("设备不存在")
|
||||
}
|
||||
|
||||
if err := s.store.DB().Model(&device).Updates(updates).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &device, nil
|
||||
}
|
||||
|
||||
// DeleteDevice 删除设备
|
||||
func (s *DeviceService) DeleteDevice(id uint64) error {
|
||||
// ✅ 使用事务保证数据一致性
|
||||
tx := s.store.DB().Begin()
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
tx.Rollback()
|
||||
}
|
||||
}()
|
||||
|
||||
var device model.Device
|
||||
if err := tx.First(&device, id).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return errors.New("设备不存在")
|
||||
}
|
||||
|
||||
// ✅ 如果设备在线,先断开连接(P2 阶段)
|
||||
if s.ctrClient != nil {
|
||||
if err := s.ctrClient.RemovePeer(device.NetworkID, device.PublicKey); err != nil {
|
||||
// 记录警告但不阻断删除流程
|
||||
fmt.Printf("⚠️ 从 WireGuard 移除 Peer 失败:network_id=%d, error=%v\n", device.NetworkID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ 清理相关路由和配置(P2 阶段)
|
||||
// ✅ P3 阶段 - 当前无额外路由配置,暂不实现
|
||||
// 未来如果需要,可以在这里添加
|
||||
|
||||
// 删除设备
|
||||
if err := tx.Delete(&device, id).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
// 提交事务
|
||||
if err := tx.Commit().Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// allocateIP 自动分配 IP 地址
|
||||
func (s *DeviceService) allocateIP(networkID uint64, subnet string) (string, error) {
|
||||
// 解析子网
|
||||
_, ipNet, err := net.ParseCIDR(subnet)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("解析子网失败:%w", err)
|
||||
}
|
||||
|
||||
// 检查是否是 IPv4 地址
|
||||
if ipNet.IP.To4() == nil {
|
||||
return "", errors.New("暂不支持 IPv6 地址分配")
|
||||
}
|
||||
|
||||
// 查询已分配的所有 IP
|
||||
var devices []model.Device
|
||||
if err := s.store.DB().Where("network_id = ?", networkID).Find(&devices).Error; err != nil {
|
||||
return "", fmt.Errorf("查询已分配 IP 失败:%w", err)
|
||||
}
|
||||
|
||||
// 构建已占用 IP 集合
|
||||
occupiedIPs := make(map[string]bool)
|
||||
for _, device := range devices {
|
||||
occupiedIPs[device.VirtualIP] = true
|
||||
}
|
||||
|
||||
// 从 .2 开始分配(.1 通常留给网关)
|
||||
// 遍历整个子网范围查找可用 IP
|
||||
ip := ipNet.IP.To4()
|
||||
startIP := ip.Mask(ipNet.Mask).To4()
|
||||
startIP[3]++ // 从 .1 开始
|
||||
|
||||
// 最多尝试 254 个 IP(/24 子网)
|
||||
for i := 1; i < 255; i++ {
|
||||
candidateIP := make(net.IP, len(startIP))
|
||||
copy(candidateIP, startIP)
|
||||
candidateIP[3] = byte(i + 1) // 从 .2 开始
|
||||
|
||||
// 检查是否被占用
|
||||
if !occupiedIPs[candidateIP.String()] {
|
||||
return candidateIP.String(), nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", errors.New("IP 地址已耗尽")
|
||||
}
|
||||
|
||||
// getSettings 获取系统设置(辅助方法)
|
||||
func (s *DeviceService) getSettings() (*model.SystemSetting, error) {
|
||||
var setting model.SystemSetting
|
||||
err := s.store.DB().First(&setting, 1).Error
|
||||
if err != nil {
|
||||
// 如果不存在,返回默认值
|
||||
return &model.SystemSetting{
|
||||
ServerPort: 51820,
|
||||
}, nil
|
||||
}
|
||||
return &setting, nil
|
||||
}
|
||||
|
||||
// generateWireGuardKeys 生成 WireGuard 密钥对
|
||||
func generateWireGuardKeys() (privateKey, publicKey string, err error) {
|
||||
// 生成私钥(32 字节随机数)
|
||||
var privKeyBytes [32]byte
|
||||
if _, err := rand.Read(privKeyBytes[:]); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
// 确保私钥符合 Curve25519 要求
|
||||
privKeyBytes[0] &= 248
|
||||
privKeyBytes[31] &= 127
|
||||
privKeyBytes[31] |= 64
|
||||
|
||||
// 从私钥推导公钥
|
||||
var pubKeyBytes [32]byte
|
||||
curve25519.ScalarBaseMult(&pubKeyBytes, &privKeyBytes)
|
||||
|
||||
// Base64 编码
|
||||
privateKey = base64.StdEncoding.EncodeToString(privKeyBytes[:])
|
||||
publicKey = base64.StdEncoding.EncodeToString(pubKeyBytes[:])
|
||||
|
||||
return privateKey, publicKey, nil
|
||||
}
|
||||
|
||||
// generatePreSharedKey 生成预共享密钥
|
||||
func generatePreSharedKey() string {
|
||||
bytes := make([]byte, 32)
|
||||
rand.Read(bytes)
|
||||
return base64.StdEncoding.EncodeToString(bytes)
|
||||
}
|
||||
|
||||
// deviceGenerateConfig 生成 WireGuard 配置文本(设备创建时使用)
|
||||
func (s *DeviceService) deviceGenerateConfig(device *model.Device, network *model.Network, privateKey string) string {
|
||||
settings, _ := s.getSettings()
|
||||
|
||||
config := "[Interface]\n"
|
||||
config += "PrivateKey = " + privateKey + "\n"
|
||||
config += "Address = " + device.VirtualIP + "/32\n"
|
||||
config += fmt.Sprintf("MTU = %d\n\n", network.MTU)
|
||||
|
||||
config += "[Peer]\n"
|
||||
if settings.ServerPublicKey != "" {
|
||||
config += "PublicKey = " + settings.ServerPublicKey + "\n"
|
||||
} else {
|
||||
config += "PublicKey = <SERVER_PUBLIC_KEY>\n"
|
||||
}
|
||||
if settings.ServerIP != "" {
|
||||
config += "Endpoint = " + settings.ServerIP + ":" + strconv.Itoa(settings.ServerPort) + "\n"
|
||||
} else {
|
||||
config += fmt.Sprintf("Endpoint = <SERVER_IP>:%d\n", settings.ServerPort)
|
||||
}
|
||||
config += "AllowedIPs = " + network.SubnetIPv4 + "\n"
|
||||
config += "PersistentKeepalive = 25\n"
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
// GenerateDeviceConfig 生成设备配置文件
|
||||
func (s *DeviceService) GenerateDeviceConfig(deviceID uint64) (string, error) {
|
||||
// 获取设备信息(包含网络)
|
||||
device, err := s.GetDevice(deviceID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// 生成 WireGuard 密钥对
|
||||
privateKey, publicKey, err := generateWireGuardKeys()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("生成密钥失败:%w", err)
|
||||
}
|
||||
|
||||
// 更新设备的公钥到数据库
|
||||
if err := s.store.DB().Model(device).Update("public_key", publicKey).Error; err != nil {
|
||||
return "", fmt.Errorf("保存公钥失败:%w", err)
|
||||
}
|
||||
|
||||
// 生成完整的 WireGuard 配置
|
||||
config := "# MeshRay Generated Configuration\n"
|
||||
config += "# Device: " + device.Name + "\n"
|
||||
config += "# Created: " + device.CreatedAt.Format("2006-01-02 15:04:05") + "\n\n"
|
||||
|
||||
// [Interface] 部分
|
||||
config += "[Interface]\n"
|
||||
config += "PrivateKey = " + privateKey + "\n"
|
||||
config += "Address = " + device.VirtualIP + "/32\n"
|
||||
config += "DNS = 8.8.8.8, 8.8.4.4\n\n"
|
||||
|
||||
// [Peer] 部分(服务端配置)
|
||||
config += "# Server (MeshRay)\n"
|
||||
config += "[Peer]\n"
|
||||
|
||||
// 从 Settings 读取服务端公钥
|
||||
settings, _ := s.getSettings()
|
||||
if settings.ServerPublicKey == "" {
|
||||
return "", errors.New("请先在系统设置中配置服务端公钥")
|
||||
}
|
||||
config += "PublicKey = " + settings.ServerPublicKey + "\n"
|
||||
|
||||
if device.PresharedKey != "" {
|
||||
config += "PresharedKey = " + device.PresharedKey + "\n"
|
||||
}
|
||||
config += "AllowedIPs = 0.0.0.0/0\n"
|
||||
|
||||
// 使用 Settings 中的 ServerIP 和 ServerPort
|
||||
if settings.ServerIP == "" {
|
||||
return "", errors.New("请先在系统设置中配置服务端 IP 地址")
|
||||
}
|
||||
config += "Endpoint = " + settings.ServerIP + ":" + strconv.Itoa(settings.ServerPort) + "\n"
|
||||
config += "PersistentKeepalive = 25\n"
|
||||
|
||||
return config, nil
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// IPDetectionService IP 检测服务
|
||||
type IPDetectionService struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// NewIPDetectionService 创建 IP 检测服务
|
||||
func NewIPDetectionService() *IPDetectionService {
|
||||
return &IPDetectionService{
|
||||
httpClient: &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// GetPublicIPv4 获取公网 IPv4 地址
|
||||
func (s *IPDetectionService) GetPublicIPv4() (string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", "https://api.ipify.org?format=json", nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("创建请求失败:%w", err)
|
||||
}
|
||||
|
||||
resp, err := s.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("请求失败:%w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("读取响应失败:%w", err)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
IP string `json:"ip"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return "", fmt.Errorf("解析 JSON 失败:%w", err)
|
||||
}
|
||||
|
||||
if result.IP == "" {
|
||||
return "", fmt.Errorf("未获取到 IPv4 地址")
|
||||
}
|
||||
|
||||
return result.IP, nil
|
||||
}
|
||||
|
||||
// GetPublicIPv6 获取公网 IPv6 地址
|
||||
func (s *IPDetectionService) GetPublicIPv6() (string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", "https://api64.ipify.org?format=json", nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("创建请求失败:%w", err)
|
||||
}
|
||||
|
||||
resp, err := s.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("请求失败:%w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("读取响应失败:%w", err)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
IP string `json:"ip"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return "", fmt.Errorf("解析 JSON 失败:%w", err)
|
||||
}
|
||||
|
||||
if result.IP == "" {
|
||||
return "", fmt.Errorf("未获取到 IPv6 地址")
|
||||
}
|
||||
|
||||
// 检查是否是 IPv6 地址(包含冒号)
|
||||
if !strings.Contains(result.IP, ":") {
|
||||
return "", fmt.Errorf("获取到的不是有效的 IPv6 地址:%s", result.IP)
|
||||
}
|
||||
|
||||
return result.IP, nil
|
||||
}
|
||||
|
||||
// GetLocalIPv4 获取本地 IPv4 地址(第一个非回环接口)
|
||||
func (s *IPDetectionService) GetLocalIPv4() (string, error) {
|
||||
addrs, err := net.InterfaceAddrs()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("获取网络接口失败:%w", err)
|
||||
}
|
||||
|
||||
for _, addr := range addrs {
|
||||
// 检查是否为 IP 地址且为 IPv4
|
||||
if ipNet, ok := addr.(*net.IPNet); ok && !ipNet.IP.IsLoopback() {
|
||||
if ipNet.IP.To4() != nil {
|
||||
return ipNet.IP.String(), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("未找到 IPv4 地址")
|
||||
}
|
||||
|
||||
// GetLocalIPv6 获取本地 IPv6 地址(第一个非回环接口)
|
||||
func (s *IPDetectionService) GetLocalIPv6() (string, error) {
|
||||
addrs, err := net.InterfaceAddrs()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("获取网络接口失败:%w", err)
|
||||
}
|
||||
|
||||
for _, addr := range addrs {
|
||||
if ipNet, ok := addr.(*net.IPNet); ok && !ipNet.IP.IsLoopback() {
|
||||
if ipNet.IP.To4() == nil && ipNet.IP.To16() != nil {
|
||||
return ipNet.IP.String(), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("未找到 IPv6 地址")
|
||||
}
|
||||
|
||||
// DetectIP 检测 IP 地址(根据记录类型返回对应的 IP)
|
||||
func (s *IPDetectionService) DetectIP(recordType string) (string, error) {
|
||||
switch recordType {
|
||||
case "A":
|
||||
// A 记录优先使用公网 IPv4
|
||||
ip, err := s.GetPublicIPv4()
|
||||
if err != nil {
|
||||
// 降级到本地 IPv4
|
||||
return s.GetLocalIPv4()
|
||||
}
|
||||
return ip, nil
|
||||
case "AAAA":
|
||||
// AAAA 记录优先使用公网 IPv6
|
||||
ip, err := s.GetPublicIPv6()
|
||||
if err != nil {
|
||||
// 降级到本地 IPv6
|
||||
return s.GetLocalIPv6()
|
||||
}
|
||||
return ip, nil
|
||||
default:
|
||||
return "", fmt.Errorf("不支持的记录类型:%s", recordType)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// IPFetcher 用于获取本机的公网 IPv4 和 IPv6
|
||||
type IPFetcher struct {
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func NewIPFetcher() *IPFetcher {
|
||||
return &IPFetcher{
|
||||
client: &http.Client{Timeout: 5 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// GetIPv4 获取公网 IPv4
|
||||
func (f *IPFetcher) GetIPv4(ctx context.Context) (string, error) {
|
||||
return f.fetchIP(ctx, "https://api.ipify.org")
|
||||
}
|
||||
|
||||
// GetIPv6 获取公网 IPv6
|
||||
func (f *IPFetcher) GetIPv6(ctx context.Context) (string, error) {
|
||||
return f.fetchIP(ctx, "https://api6.ipify.org")
|
||||
}
|
||||
|
||||
func (f *IPFetcher) fetchIP(ctx context.Context, apiURL string) (string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
resp, err := f.client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", http.ErrServerClosed // 简易错误
|
||||
}
|
||||
|
||||
ipBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(string(ipBytes)), nil
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.zkcoi.com/zkcoi/meshray/internal/model"
|
||||
sqlite "git.zkcoi.com/zkcoi/meshray/internal/store/sqlite"
|
||||
"go.uber.org/zap"
|
||||
"golang.org/x/crypto/ed25519"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// MeshSeedService MeshSeed 服务
|
||||
type MeshSeedService struct {
|
||||
store *sqlite.Store
|
||||
logger *zap.Logger
|
||||
signingKey ed25519.PrivateKey // Ed25519 签名密钥
|
||||
issuerNodeID string // 签发节点 ID
|
||||
}
|
||||
|
||||
// NewMeshSeedService 创建 MeshSeed 服务
|
||||
func NewMeshSeedService(store *sqlite.Store, logger *zap.Logger, signingKey ed25519.PrivateKey, issuerNodeID string) *MeshSeedService {
|
||||
return &MeshSeedService{
|
||||
store: store,
|
||||
logger: logger,
|
||||
signingKey: signingKey,
|
||||
issuerNodeID: issuerNodeID,
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateMeshSeed 生成 MeshSeed
|
||||
func (s *MeshSeedService) GenerateMeshSeed(networkID uint64, maxUses int, expiresAt time.Time, ddnsEnabled bool) (*model.MeshSeed, error) {
|
||||
// 1. 验证网络是否存在
|
||||
var network model.Network
|
||||
if err := s.store.DB().First(&network, networkID).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, fmt.Errorf("网络不存在")
|
||||
}
|
||||
return nil, fmt.Errorf("查询网络失败:%w", err)
|
||||
}
|
||||
|
||||
// 2. 生成随机 SeedID(16 字节随机数)
|
||||
seedBytes := make([]byte, 16)
|
||||
if _, err := rand.Read(seedBytes); err != nil {
|
||||
return nil, fmt.Errorf("生成随机数失败:%w", err)
|
||||
}
|
||||
seedID := base64.RawURLEncoding.EncodeToString(seedBytes)
|
||||
|
||||
// 3. 构建 JoinToken(包含网络信息)
|
||||
joinTokenData := map[string]interface{}{
|
||||
"seed_id": seedID,
|
||||
"network_id": networkID,
|
||||
"network_name": network.Name,
|
||||
"subnet_ipv4": network.SubnetIPv4,
|
||||
"mode": network.Mode,
|
||||
"ddns_enabled": ddnsEnabled,
|
||||
"expires_at": expiresAt.Unix(),
|
||||
"max_uses": maxUses,
|
||||
}
|
||||
|
||||
// 序列化为 JSON
|
||||
tokenJSON, err := json.Marshal(joinTokenData)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("序列化 Token 失败:%w", err)
|
||||
}
|
||||
|
||||
// Base64 编码
|
||||
joinToken := base64.StdEncoding.EncodeToString(tokenJSON)
|
||||
|
||||
// 4. Ed25519 签名
|
||||
signature := ed25519.Sign(s.signingKey, []byte(joinToken))
|
||||
signatureStr := base64.StdEncoding.EncodeToString(signature)
|
||||
|
||||
// 5. 创建 MeshSeed 记录
|
||||
meshSeed := &model.MeshSeed{
|
||||
SeedID: seedID,
|
||||
NetworkID: networkID,
|
||||
JoinToken: joinToken,
|
||||
Signature: signatureStr,
|
||||
IssuerNodeID: s.issuerNodeID,
|
||||
MaxUses: maxUses,
|
||||
UsedCount: 0,
|
||||
ExpiresAt: expiresAt,
|
||||
DDNSEnabled: ddnsEnabled,
|
||||
UpdateVersion: 0,
|
||||
Revoked: false,
|
||||
}
|
||||
|
||||
if err := s.store.DB().Create(meshSeed).Error; err != nil {
|
||||
return nil, fmt.Errorf("创建 MeshSeed 失败:%w", err)
|
||||
}
|
||||
|
||||
s.logger.Info("MeshSeed 已生成",
|
||||
zap.String("seed_id", seedID),
|
||||
zap.Uint64("network_id", networkID),
|
||||
zap.Int("max_uses", maxUses),
|
||||
zap.Time("expires_at", expiresAt))
|
||||
|
||||
return meshSeed, nil
|
||||
}
|
||||
|
||||
// VerifyMeshSeed 验证 MeshSeed
|
||||
func (s *MeshSeedService) VerifyMeshSeed(joinToken, signature string) (*model.MeshSeed, error) {
|
||||
// 1. 解码 JoinToken
|
||||
tokenBytes, err := base64.StdEncoding.DecodeString(joinToken)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("解码 Token 失败:%w", err)
|
||||
}
|
||||
|
||||
// 2. 解码签名
|
||||
sigBytes, err := base64.StdEncoding.DecodeString(signature)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("解码签名失败:%w", err)
|
||||
}
|
||||
|
||||
// 3. 验证 Ed25519 签名
|
||||
publicKey := s.signingKey.Public()
|
||||
if !ed25519.Verify(publicKey.(ed25519.PublicKey), tokenBytes, sigBytes) {
|
||||
return nil, fmt.Errorf("签名验证失败")
|
||||
}
|
||||
|
||||
// 4. 解析 Token 内容
|
||||
var tokenData map[string]interface{}
|
||||
if err := json.Unmarshal(tokenBytes, &tokenData); err != nil {
|
||||
return nil, fmt.Errorf("解析 Token 失败:%w", err)
|
||||
}
|
||||
|
||||
seedID, ok := tokenData["seed_id"].(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("Token 格式错误")
|
||||
}
|
||||
|
||||
// 5. 查询 MeshSeed
|
||||
var meshSeed model.MeshSeed
|
||||
if err := s.store.DB().Where("seed_id = ?", seedID).First(&meshSeed).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, fmt.Errorf("MeshSeed 不存在")
|
||||
}
|
||||
return nil, fmt.Errorf("查询 MeshSeed 失败:%w", err)
|
||||
}
|
||||
|
||||
// 6. 检查是否被吊销
|
||||
if meshSeed.Revoked {
|
||||
return nil, fmt.Errorf("MeshSeed 已被吊销")
|
||||
}
|
||||
|
||||
// 7. 检查使用次数
|
||||
if meshSeed.UsedCount >= meshSeed.MaxUses {
|
||||
return nil, fmt.Errorf("MeshSeed 使用次数已用尽")
|
||||
}
|
||||
|
||||
// 8. 检查过期时间
|
||||
if time.Now().After(meshSeed.ExpiresAt) {
|
||||
return nil, fmt.Errorf("MeshSeed 已过期")
|
||||
}
|
||||
|
||||
return &meshSeed, nil
|
||||
}
|
||||
|
||||
// IncrementUseCount 增加使用次数
|
||||
func (s *MeshSeedService) IncrementUseCount(seedID string) error {
|
||||
return s.store.DB().Transaction(func(tx *gorm.DB) error {
|
||||
var meshSeed model.MeshSeed
|
||||
if err := tx.Where("seed_id = ?", seedID).First(&meshSeed).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return tx.Model(&meshSeed).UpdateColumn("used_count", meshSeed.UsedCount+1).Error
|
||||
})
|
||||
}
|
||||
|
||||
// RevokeMeshSeed 吊销 MeshSeed
|
||||
func (s *MeshSeedService) RevokeMeshSeed(seedID string) error {
|
||||
result := s.store.DB().Model(&model.MeshSeed{}).
|
||||
Where("seed_id = ?", seedID).
|
||||
Update("revoked", true)
|
||||
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("吊销 MeshSeed 失败:%w", result.Error)
|
||||
}
|
||||
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("MeshSeed 不存在")
|
||||
}
|
||||
|
||||
s.logger.Info("MeshSeed 已吊销", zap.String("seed_id", seedID))
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListMeshSeeds 获取网络的 MeshSeed 列表
|
||||
func (s *MeshSeedService) ListMeshSeeds(networkID uint) ([]model.MeshSeed, error) {
|
||||
var seeds []model.MeshSeed
|
||||
err := s.store.DB().Where("network_id = ? AND revoked = ?", networkID, false).
|
||||
Order("created_at DESC").
|
||||
Find(&seeds).Error
|
||||
|
||||
return seeds, err
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
|
||||
"git.zkcoi.com/zkcoi/meshray/internal/ctr"
|
||||
"git.zkcoi.com/zkcoi/meshray/internal/model"
|
||||
"git.zkcoi.com/zkcoi/meshray/internal/store/sqlite"
|
||||
"git.zkcoi.com/zkcoi/meshray/pkg/idutil"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// NetworkService 网络管理服务
|
||||
type NetworkService struct {
|
||||
store *sqlite.Store
|
||||
ctrClient ctr.Client // meshray-ctr 客户端
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewNetworkService 创建网络服务实例
|
||||
func NewNetworkService(store *sqlite.Store, ctrClient ctr.Client, logger *zap.Logger) *NetworkService {
|
||||
return &NetworkService{
|
||||
store: store,
|
||||
ctrClient: ctrClient,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// ListNetworks 获取网络列表(Service 层方法)
|
||||
func (s *NetworkService) ListNetworks() ([]model.Network, error) {
|
||||
var networks []model.Network
|
||||
err := s.store.DB().Preload("Devices").Find(&networks).Error
|
||||
return networks, err
|
||||
}
|
||||
|
||||
// GetNetworkByID 根据 ID 获取网络
|
||||
func (s *NetworkService) GetNetworkByID(id uint64) (*model.Network, error) {
|
||||
var network model.Network
|
||||
err := s.store.DB().Preload("Devices").First(&network, id).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &network, nil
|
||||
}
|
||||
|
||||
// CreateNetwork 创建网络
|
||||
func (s *NetworkService) CreateNetwork(req *model.Network) (*model.Network, error) {
|
||||
// 验证子网格式
|
||||
if err := s.validateSubnet(req.SubnetIPv4); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 检查网络名称是否重复
|
||||
var existing model.Network
|
||||
if err := s.store.DB().Where("name = ?", req.Name).First(&existing).Error; err == nil {
|
||||
return nil, errors.New("网络名称已存在")
|
||||
}
|
||||
|
||||
// 生成雪花算法 ID
|
||||
if req.ID == 0 {
|
||||
gen, err := idutil.GetGenerator()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get snowflake generator: %w", err)
|
||||
}
|
||||
req.ID, err = gen.NextID()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate network id: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 计算 listenPort:51820 + hash(networkID)(先不保存,用于 ctr 调用)
|
||||
listenPort := 51820 + hashUint64(req.ID)%1000
|
||||
|
||||
// 根据组网模式决定是否启动 Core
|
||||
// 原生模式:仅创建 WG 设备
|
||||
// 增强模式:创建 WG 设备 + 启动 Core 实例
|
||||
if s.ctrClient != nil {
|
||||
// P2 阶段 - 先调用 meshray-ctr 创建 WireGuard 设备(失败则不回滚数据库)
|
||||
// 策略:宽松模式 - ctr 失败只记录警告,不影响数据库操作
|
||||
if err := s.ctrClient.CreateNetwork(req.ID, req.SubnetIPv4, listenPort, req.Mode); err != nil {
|
||||
// 记录错误但不影响数据库操作(允许降级)
|
||||
s.logger.Warn("调用 ctr 创建网络失败,将手动启动",
|
||||
zap.Uint64("network_id", req.ID),
|
||||
zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// 保存到数据库
|
||||
if err := s.store.DB().Create(req).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.logger.Info("网络创建成功",
|
||||
zap.Uint64("network_id", req.ID),
|
||||
zap.String("mode", req.Mode))
|
||||
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// GetNetwork 获取网络详情
|
||||
func (s *NetworkService) GetNetwork(id uint64) (*model.Network, error) {
|
||||
var network model.Network
|
||||
if err := s.store.DB().First(&network, id).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, errors.New("网络不存在")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &network, nil
|
||||
}
|
||||
|
||||
// UpdateNetwork 更新网络
|
||||
func (s *NetworkService) UpdateNetwork(id uint64, updates map[string]interface{}) (*model.Network, error) {
|
||||
var network model.Network
|
||||
if err := s.store.DB().First(&network, id).Error; err != nil {
|
||||
return nil, errors.New("网络不存在")
|
||||
}
|
||||
|
||||
if err := s.store.DB().Model(&network).Updates(updates).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &network, nil
|
||||
}
|
||||
|
||||
// DeleteNetwork 删除网络
|
||||
func (s *NetworkService) DeleteNetwork(id uint64, force bool) error {
|
||||
// ✅ 使用事务保证数据一致性
|
||||
tx := s.store.DB().Begin()
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
tx.Rollback()
|
||||
}
|
||||
}()
|
||||
|
||||
var network model.Network
|
||||
if err := tx.First(&network, id).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return errors.New("网络不存在")
|
||||
}
|
||||
|
||||
// 检查是否有关联的设备
|
||||
var deviceCount int64
|
||||
tx.Model(&model.Device{}).Where("network_id = ?", id).Count(&deviceCount)
|
||||
if deviceCount > 0 {
|
||||
if !force {
|
||||
tx.Rollback()
|
||||
return errors.New("该网络下仍有设备,为避免误操作,请确认后强制删除")
|
||||
}
|
||||
// 级联删除所有关联设备
|
||||
if err := tx.Where("network_id = ?", id).Delete(&model.Device{}).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return fmt.Errorf("级联删除设备失败:%w", err)
|
||||
}
|
||||
s.logger.Info("级联删除了关联设备", zap.Uint64("network_id", id), zap.Int64("device_count", deviceCount))
|
||||
}
|
||||
|
||||
// 先调用 ctr 删除 WG 设备(如果存在)
|
||||
if s.ctrClient != nil {
|
||||
if err := s.ctrClient.DeleteNetwork(id); err != nil {
|
||||
// 记录错误但不中断删除流程(设备可能已经不存在)
|
||||
s.logger.Warn("删除 WG 设备失败(可能已不存在)",
|
||||
zap.Uint64("network_id", id),
|
||||
zap.Error(err))
|
||||
// 继续删除数据库记录
|
||||
}
|
||||
}
|
||||
|
||||
// 再删除数据库记录
|
||||
if err := tx.Delete(&network, id).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
// 提交事务
|
||||
if err := tx.Commit().Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateSubnet 验证子网格式
|
||||
func (s *NetworkService) validateSubnet(subnet string) error {
|
||||
// 只调用一次 ParseCIDR
|
||||
ip, ipNet, err := net.ParseCIDR(subnet)
|
||||
if err != nil {
|
||||
return fmt.Errorf("无效的子网格式:%s", subnet)
|
||||
}
|
||||
|
||||
// 检查是否是有效的 WireGuard 子网(至少 /24 或更小)
|
||||
ones, bits := ipNet.Mask.Size()
|
||||
|
||||
if bits == 32 && ones > 24 {
|
||||
return errors.New("IPv4 子网掩码不能大于 /24")
|
||||
}
|
||||
if bits == 128 && ones > 64 {
|
||||
return errors.New("IPv6 子网掩码不能大于 /64")
|
||||
}
|
||||
|
||||
// 检查是否是私有地址段
|
||||
if !isPrivateSubnet(ip, ipNet) {
|
||||
return errors.New("请使用私有地址段(如 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16)")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// isPrivateSubnet 检查是否是私有地址段
|
||||
func isPrivateSubnet(ip net.IP, ipNet *net.IPNet) bool {
|
||||
// IPv4 私有地址段
|
||||
privateRanges := []string{
|
||||
"10.0.0.0/8",
|
||||
"172.16.0.0/12",
|
||||
"192.168.0.0/16",
|
||||
"100.64.0.0/10", // CGNAT
|
||||
}
|
||||
|
||||
for _, privRange := range privateRanges {
|
||||
_, privNet, _ := net.ParseCIDR(privRange)
|
||||
if privNet.Contains(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// generateNetworkSecret 生成 Network Secret
|
||||
func generateNetworkSecret() string {
|
||||
// 使用加密安全的随机数生成器
|
||||
bytes := make([]byte, 32)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
// ✅ 随机数生成失败时 panic,而不是返回弱密码
|
||||
panic(fmt.Sprintf("生成安全随机数失败:%v", err))
|
||||
}
|
||||
return hex.EncodeToString(bytes)
|
||||
}
|
||||
|
||||
// hashUint64 计算 uint64 哈希值
|
||||
func hashUint64(id uint64) int {
|
||||
h := sha256.Sum256([]byte(fmt.Sprintf("%d", id)))
|
||||
// 取前 4 字节转换为 int
|
||||
return int(h[0])<<24 | int(h[1])<<16 | int(h[2])<<8 | int(h[3])
|
||||
}
|
||||
|
||||
// StartNetwork 启动网络
|
||||
func (s *NetworkService) StartNetwork(id uint64) error {
|
||||
network, err := s.GetNetworkByID(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 如果已经有 ctr,直接调用 CreateNetwork
|
||||
if s.ctrClient != nil {
|
||||
listenPort := 51820 + hashUint64(network.ID)%1000
|
||||
if err := s.ctrClient.CreateNetwork(network.ID, network.SubnetIPv4, listenPort, network.Mode); err != nil {
|
||||
s.logger.Error("启动网络失败",
|
||||
zap.Uint64("network_id", network.ID),
|
||||
zap.Error(err))
|
||||
return fmt.Errorf("启动网络失败:%w", err)
|
||||
}
|
||||
|
||||
s.logger.Info("网络已启动",
|
||||
zap.Uint64("network_id", network.ID),
|
||||
zap.Int("listen_port", listenPort))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// StopNetwork 停止网络
|
||||
func (s *NetworkService) StopNetwork(id uint64) error {
|
||||
network, err := s.GetNetworkByID(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 调用 ctr 删除网络(清理 WG 设备)
|
||||
if s.ctrClient != nil {
|
||||
if err := s.ctrClient.DeleteNetwork(network.ID); err != nil {
|
||||
s.logger.Error("停止网络失败",
|
||||
zap.Uint64("network_id", network.ID),
|
||||
zap.Error(err))
|
||||
return fmt.Errorf("停止网络失败:%w", err)
|
||||
}
|
||||
|
||||
s.logger.Info("网络已停止",
|
||||
zap.Uint64("network_id", network.ID))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SwitchMode 切换组网模式
|
||||
func (s *NetworkService) SwitchMode(id uint64, meshMode string) error {
|
||||
network, err := s.GetNetworkByID(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 更新数据库
|
||||
if err := s.store.DB().Model(network).Update("mode", meshMode).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.logger.Info("组网模式已切换",
|
||||
zap.Uint64("network_id", network.ID),
|
||||
zap.String("mode", meshMode))
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.zkcoi.com/zkcoi/meshray/internal/model"
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// NotificationService 通知服务
|
||||
type NotificationService struct {
|
||||
db *gorm.DB
|
||||
logger *zap.Logger
|
||||
clients map[uint]*NotificationClient // userID -> client
|
||||
mu sync.RWMutex
|
||||
broadcastCh chan NotificationMessage
|
||||
}
|
||||
|
||||
// NotificationClient WebSocket 通知客户端
|
||||
type NotificationClient struct {
|
||||
userID uint
|
||||
username string
|
||||
conn *gin.Context
|
||||
msgCh chan NotificationMessage
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
// NotificationMessage 通知消息
|
||||
type NotificationMessage struct {
|
||||
Type string `json:"type"` // alert, system, update, ddns
|
||||
Priority int `json:"priority"` // 1=low, 2=medium, 3=high
|
||||
Title string `json:"title"`
|
||||
Message string `json:"message"`
|
||||
Data map[string]interface{} `json:"data,omitempty"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
}
|
||||
|
||||
// NewNotificationService 创建通知服务
|
||||
func NewNotificationService(db *gorm.DB, logger *zap.Logger) *NotificationService {
|
||||
svc := &NotificationService{
|
||||
db: db,
|
||||
logger: logger,
|
||||
clients: make(map[uint]*NotificationClient),
|
||||
broadcastCh: make(chan NotificationMessage, 100),
|
||||
}
|
||||
|
||||
// 启动广播协程
|
||||
go svc.runBroadcaster()
|
||||
|
||||
return svc
|
||||
}
|
||||
|
||||
// runBroadcaster 运行广播协程
|
||||
func (s *NotificationService) runBroadcaster() {
|
||||
for msg := range s.broadcastCh {
|
||||
s.mu.RLock()
|
||||
for _, client := range s.clients {
|
||||
select {
|
||||
case client.msgCh <- msg:
|
||||
// 发送成功
|
||||
default:
|
||||
// 通道已满,跳过
|
||||
s.logger.Warn("通知通道已满", zap.Uint("user_id", client.userID))
|
||||
}
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterClient 注册通知客户端
|
||||
func (s *NotificationService) RegisterClient(userID uint, username string, msgCh chan NotificationMessage, done chan struct{}) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
client := &NotificationClient{
|
||||
userID: userID,
|
||||
username: username,
|
||||
msgCh: msgCh,
|
||||
done: done,
|
||||
}
|
||||
|
||||
s.clients[userID] = client
|
||||
s.logger.Info("用户已连接通知服务", zap.Uint("user_id", userID), zap.String("username", username))
|
||||
}
|
||||
|
||||
// UnregisterClient 注销通知客户端
|
||||
func (s *NotificationService) UnregisterClient(userID uint) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if client, ok := s.clients[userID]; ok {
|
||||
close(client.done)
|
||||
delete(s.clients, userID)
|
||||
s.logger.Info("用户已断开通知服务", zap.Uint("user_id", userID))
|
||||
}
|
||||
}
|
||||
|
||||
// SendToUser 发送通知给指定用户(并保存到数据库)
|
||||
func (s *NotificationService) SendToUser(userID uint, msg NotificationMessage) {
|
||||
// 1. 保存到数据库
|
||||
notification := model.Notification{
|
||||
UserID: userID,
|
||||
Type: msg.Type,
|
||||
Priority: msg.Priority,
|
||||
Title: msg.Title,
|
||||
Message: msg.Message,
|
||||
}
|
||||
|
||||
if msg.Data != nil {
|
||||
dataJSON, _ := json.Marshal(msg.Data)
|
||||
notification.Data = string(dataJSON)
|
||||
}
|
||||
|
||||
if err := s.db.Create(¬ification).Error; err != nil {
|
||||
s.logger.Error("保存通知失败", zap.Error(err))
|
||||
}
|
||||
|
||||
// 2. 发送到 WebSocket 通道
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
if client, ok := s.clients[userID]; ok {
|
||||
select {
|
||||
case client.msgCh <- msg:
|
||||
s.logger.Debug("通知已发送给用户",
|
||||
zap.Uint("user_id", userID),
|
||||
zap.String("type", msg.Type))
|
||||
default:
|
||||
s.logger.Warn("用户通知通道已满", zap.Uint("user_id", userID))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Broadcast 广播通知给所有在线用户(并保存到数据库)
|
||||
func (s *NotificationService) Broadcast(msg NotificationMessage) {
|
||||
msg.Timestamp = time.Now()
|
||||
|
||||
// 保存到所有用户的数据库记录
|
||||
s.mu.RLock()
|
||||
for userID := range s.clients {
|
||||
notification := model.Notification{
|
||||
UserID: userID,
|
||||
Type: msg.Type,
|
||||
Priority: msg.Priority,
|
||||
Title: msg.Title,
|
||||
Message: msg.Message,
|
||||
}
|
||||
|
||||
if msg.Data != nil {
|
||||
dataJSON, _ := json.Marshal(msg.Data)
|
||||
notification.Data = string(dataJSON)
|
||||
}
|
||||
|
||||
s.db.Create(¬ification)
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
|
||||
// 发送到 WebSocket 通道
|
||||
s.broadcastCh <- msg
|
||||
s.logger.Debug("通知已广播",
|
||||
zap.String("type", msg.Type),
|
||||
zap.Int("online_users", len(s.clients)))
|
||||
}
|
||||
|
||||
// SendAlert 发送告警通知
|
||||
func (s *NotificationService) SendAlert(userID uint, title, message string, data map[string]interface{}) {
|
||||
msg := NotificationMessage{
|
||||
Type: "alert",
|
||||
Priority: 3, // high priority
|
||||
Title: title,
|
||||
Message: message,
|
||||
Data: data,
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
s.SendToUser(userID, msg)
|
||||
}
|
||||
|
||||
// SendSystemNotification 发送系统通知
|
||||
func (s *NotificationService) SendSystemNotification(userID uint, title, message string) {
|
||||
msg := NotificationMessage{
|
||||
Type: "system",
|
||||
Priority: 2, // medium priority
|
||||
Title: title,
|
||||
Message: message,
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
s.SendToUser(userID, msg)
|
||||
}
|
||||
|
||||
// SendUpdateAvailable 发送更新可用通知
|
||||
func (s *NotificationService) SendUpdateAvailable(version, notes, downloadURL string) {
|
||||
msg := NotificationMessage{
|
||||
Type: "update",
|
||||
Priority: 2,
|
||||
Title: "发现新版本",
|
||||
Message: version,
|
||||
Data: map[string]interface{}{
|
||||
"version": version,
|
||||
"notes": notes,
|
||||
"download_url": downloadURL,
|
||||
},
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
s.Broadcast(msg)
|
||||
}
|
||||
|
||||
// SendDDNSUpdate 发送 DDNS 更新通知
|
||||
func (s *NotificationService) SendDDNSUpdate(serviceName, oldIP, newIP string) {
|
||||
data, _ := json.Marshal(gin.H{
|
||||
"service_name": serviceName,
|
||||
"old_ip": oldIP,
|
||||
"new_ip": newIP,
|
||||
})
|
||||
|
||||
var dataMap map[string]interface{}
|
||||
json.Unmarshal(data, &dataMap)
|
||||
|
||||
msg := NotificationMessage{
|
||||
Type: "ddns",
|
||||
Priority: 1, // low priority
|
||||
Title: "DDNS IP 已更新",
|
||||
Message: serviceName,
|
||||
Data: dataMap,
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
s.Broadcast(msg)
|
||||
}
|
||||
|
||||
// GetOnlineUserCount 获取在线用户数
|
||||
func (s *NotificationService) GetOnlineUserCount() int {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return len(s.clients)
|
||||
}
|
||||
|
||||
// GetDB 返回数据库实例(用于 Handler 层查询)
|
||||
func (s *NotificationService) GetDB() *gorm.DB {
|
||||
return s.db
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.zkcoi.com/zkcoi/meshray/internal/model"
|
||||
"git.zkcoi.com/zkcoi/meshray/internal/store/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// PendingJoinService 待审核服务
|
||||
type PendingJoinService struct {
|
||||
store *sqlite.Store
|
||||
}
|
||||
|
||||
// NewPendingJoinService 创建待审核服务实例
|
||||
func NewPendingJoinService(store *sqlite.Store) *PendingJoinService {
|
||||
return &PendingJoinService{store: store}
|
||||
}
|
||||
|
||||
// ListPendingJoins 获取待审核列表
|
||||
func (s *PendingJoinService) ListPendingJoins(networkID uint, status string, page, size int) ([]model.PendingJoin, int64, error) {
|
||||
query := s.store.DB().Model(&model.PendingJoin{})
|
||||
|
||||
// 按网络 ID 筛选
|
||||
if networkID > 0 {
|
||||
query = query.Where("network_id = ?", networkID)
|
||||
}
|
||||
|
||||
// 按状态筛选
|
||||
if status != "" && status != "all" {
|
||||
query = query.Where("status = ?", status)
|
||||
}
|
||||
|
||||
// 统计总数
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
// 分页查询
|
||||
var result []model.PendingJoin
|
||||
offset := (page - 1) * size
|
||||
err := query.Order("created_at DESC").Offset(offset).Limit(size).Find(&result).Error
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return result, total, nil
|
||||
}
|
||||
|
||||
// GetPendingJoinByID 根据 ID 获取待审核记录
|
||||
func (s *PendingJoinService) GetPendingJoinByID(id uint) (*model.PendingJoin, error) {
|
||||
var record model.PendingJoin
|
||||
err := s.store.DB().First(&record, id).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, errors.New("记录不存在")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &record, nil
|
||||
}
|
||||
|
||||
// ApproveResult 审核通过结果
|
||||
type ApproveResult struct {
|
||||
Device *model.Device `json:"device"`
|
||||
PrivateKey string `json:"private_key"` // 仅首次返回
|
||||
Network *model.Network `json:"network"`
|
||||
ConfigText string `json:"config_text"` // WireGuard 配置文本
|
||||
}
|
||||
|
||||
// ApproveJoin 审核通过
|
||||
func (s *PendingJoinService) ApproveJoin(id uint) (*ApproveResult, error) {
|
||||
var record model.PendingJoin
|
||||
if err := s.store.DB().First(&record, id).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, errors.New("记录不存在")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if record.Status != "pending" {
|
||||
return nil, errors.New("该申请已处理")
|
||||
}
|
||||
|
||||
// 1. 查询 MeshSeed 获取网络信息
|
||||
var meshSeed model.MeshSeed
|
||||
if err := s.store.DB().Where("seed_id = ?", record.SeedID).First(&meshSeed).Error; err != nil {
|
||||
return nil, fmt.Errorf("查询 MeshSeed 失败:%w", err)
|
||||
}
|
||||
|
||||
// 2. 查询网络详情
|
||||
var network model.Network
|
||||
if err := s.store.DB().First(&network, meshSeed.NetworkID).Error; err != nil {
|
||||
return nil, fmt.Errorf("查询网络失败:%w", err)
|
||||
}
|
||||
|
||||
// 3. 生成 WireGuard 密钥对(调用全局函数,定义在 device.go 中)
|
||||
privateKey, publicKey, err := generateWireGuardKeys()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("生成密钥失败:%w", err)
|
||||
}
|
||||
|
||||
// 4. 分配 IP 地址
|
||||
ipAddress, err := s.allocateIPAddress(&network)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("分配 IP 失败:%w", err)
|
||||
}
|
||||
|
||||
// 5. 创建设备记录
|
||||
device := &model.Device{
|
||||
Name: record.DeviceName,
|
||||
NetworkID: network.ID,
|
||||
PublicKey: publicKey,
|
||||
VirtualIP: ipAddress,
|
||||
Status: "active",
|
||||
LastSeen: time.Now(),
|
||||
}
|
||||
|
||||
if err := s.store.DB().Create(device).Error; err != nil {
|
||||
return nil, fmt.Errorf("创建设备失败:%w", err)
|
||||
}
|
||||
|
||||
// 6. 更新审核状态
|
||||
now := time.Now()
|
||||
record.Status = "approved"
|
||||
record.ApprovedAt = &now
|
||||
if err := s.store.DB().Save(&record).Error; err != nil {
|
||||
return nil, fmt.Errorf("更新审核状态失败:%w", err)
|
||||
}
|
||||
|
||||
// 7. 生成 WireGuard 配置文本(使用 pending join 专用方法,读取 SystemSetting)
|
||||
configText := s.pendingJoinGenerateConfig(device, &network, privateKey)
|
||||
|
||||
return &ApproveResult{
|
||||
Device: device,
|
||||
PrivateKey: privateKey,
|
||||
Network: &network,
|
||||
ConfigText: configText,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// RejectJoin 审核拒绝
|
||||
func (s *PendingJoinService) RejectJoin(id uint, reason string) error {
|
||||
var record model.PendingJoin
|
||||
if err := s.store.DB().First(&record, id).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return errors.New("记录不存在")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if record.Status != "pending" {
|
||||
return errors.New("该申请已处理")
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
record.Status = "rejected"
|
||||
record.RejectedAt = &now
|
||||
record.Reason = reason
|
||||
|
||||
return s.store.DB().Save(&record).Error
|
||||
}
|
||||
|
||||
// allocateIPAddress 分配 IP 地址
|
||||
func (s *PendingJoinService) allocateIPAddress(network *model.Network) (string, error) {
|
||||
// 解析子网
|
||||
_, ipNet, err := net.ParseCIDR(network.SubnetIPv4)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("解析子网失败:%w", err)
|
||||
}
|
||||
|
||||
// 获取已使用的 IP
|
||||
var devices []model.Device
|
||||
if err := s.store.DB().Where("network_id = ?", network.ID).Find(&devices).Error; err != nil {
|
||||
return "", fmt.Errorf("查询设备列表失败:%w", err)
|
||||
}
|
||||
|
||||
usedIPs := make(map[string]bool)
|
||||
for _, device := range devices {
|
||||
usedIPs[device.VirtualIP] = true
|
||||
}
|
||||
|
||||
// 从 .2 开始分配(.1 通常是网关)
|
||||
ip := ipNet.IP.To4()
|
||||
if ip == nil {
|
||||
return "", errors.New("暂不支持 IPv6 地址分配")
|
||||
}
|
||||
for i := 2; i < 254; i++ {
|
||||
candidateIP := make(net.IP, len(ip))
|
||||
copy(candidateIP, ip)
|
||||
candidateIP[3] = byte(i)
|
||||
if !usedIPs[candidateIP.String()] {
|
||||
return candidateIP.String(), nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", errors.New("IP 地址已用尽")
|
||||
}
|
||||
|
||||
// pendingJoinGenerateConfig 生成 WireGuard 配置文本(从 SystemSetting 获取服务端信息)
|
||||
func (s *PendingJoinService) pendingJoinGenerateConfig(device *model.Device, network *model.Network, privateKey string) string {
|
||||
// 从 SystemSetting 获取服务端信息
|
||||
var settings model.SystemSetting
|
||||
if err := s.store.DB().First(&settings, 1).Error; err != nil {
|
||||
// 使用默认值
|
||||
settings.ServerPort = 51820
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString("[Interface]\n")
|
||||
sb.WriteString(fmt.Sprintf("PrivateKey = %s\n", privateKey))
|
||||
sb.WriteString(fmt.Sprintf("Address = %s/32\n", device.VirtualIP))
|
||||
sb.WriteString(fmt.Sprintf("MTU = %d\n\n", network.MTU))
|
||||
|
||||
sb.WriteString("[Peer]\n")
|
||||
if settings.ServerPublicKey != "" {
|
||||
sb.WriteString(fmt.Sprintf("PublicKey = %s\n", settings.ServerPublicKey))
|
||||
} else {
|
||||
sb.WriteString("PublicKey = <SERVER_PUBLIC_KEY>\n")
|
||||
}
|
||||
if settings.ServerIP != "" {
|
||||
sb.WriteString(fmt.Sprintf("Endpoint = %s:%d\n", settings.ServerIP, settings.ServerPort))
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf("Endpoint = <SERVER_IP>:%d\n", settings.ServerPort))
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("AllowedIPs = %s\n", network.SubnetIPv4))
|
||||
sb.WriteString("PersistentKeepalive = 25\n")
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// DeleteExpired 删除过期的待审核记录
|
||||
func (s *PendingJoinService) DeleteExpired() error {
|
||||
return s.store.DB().Where("expire_at < ? AND status = ?", time.Now(), "pending").Delete(&model.PendingJoin{}).Error
|
||||
}
|
||||
|
||||
// CountPending 统计待审核数量
|
||||
func (s *PendingJoinService) CountPending() (int64, error) {
|
||||
var count int64
|
||||
err := s.store.DB().Model(&model.PendingJoin{}).Where("status = ?", "pending").Count(&count).Error
|
||||
return count, err
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"git.zkcoi.com/zkcoi/meshray/internal/model"
|
||||
"git.zkcoi.com/zkcoi/meshray/internal/store/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// PolicyService 策略管理服务
|
||||
type PolicyService struct {
|
||||
store *sqlite.Store
|
||||
}
|
||||
|
||||
// NewPolicyService 创建策略服务实例
|
||||
func NewPolicyService(store *sqlite.Store) *PolicyService {
|
||||
return &PolicyService{store: store}
|
||||
}
|
||||
|
||||
// ListPolicies 获取策略列表
|
||||
func (s *PolicyService) ListPolicies() ([]model.Policy, error) {
|
||||
var policies []model.Policy
|
||||
err := s.store.DB().Order("created_at desc").Find(&policies).Error
|
||||
return policies, err
|
||||
}
|
||||
|
||||
// GetPolicyByID 根据 ID 获取策略
|
||||
func (s *PolicyService) GetPolicyByID(id uint) (*model.Policy, error) {
|
||||
var policy model.Policy
|
||||
if err := s.store.DB().First(&policy, id).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, errors.New("策略不存在")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &policy, nil
|
||||
}
|
||||
|
||||
// CreatePolicy 创建策略
|
||||
func (s *PolicyService) CreatePolicy(req *model.Policy) (*model.Policy, error) {
|
||||
if req.Name == "" {
|
||||
return nil, errors.New("策略名称不能为空")
|
||||
}
|
||||
|
||||
// 检查名称是否重复
|
||||
var existing model.Policy
|
||||
if err := s.store.DB().Where("name = ?", req.Name).First(&existing).Error; err == nil {
|
||||
return nil, errors.New("策略名称已存在")
|
||||
}
|
||||
|
||||
// 默认值
|
||||
if req.Type == "" {
|
||||
req.Type = "custom"
|
||||
}
|
||||
req.Enabled = true
|
||||
|
||||
if err := s.store.DB().Create(req).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// UpdatePolicy 更新策略
|
||||
func (s *PolicyService) UpdatePolicy(id uint, updates map[string]interface{}) (*model.Policy, error) {
|
||||
var policy model.Policy
|
||||
if err := s.store.DB().First(&policy, id).Error; err != nil {
|
||||
return nil, errors.New("策略不存在")
|
||||
}
|
||||
|
||||
// 系统默认策略不可编辑
|
||||
if policy.Type == "system" {
|
||||
return nil, errors.New("系统默认策略不可编辑")
|
||||
}
|
||||
|
||||
if err := s.store.DB().Model(&policy).Updates(updates).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &policy, nil
|
||||
}
|
||||
|
||||
// DeletePolicy 删除策略
|
||||
func (s *PolicyService) DeletePolicy(id uint) error {
|
||||
var policy model.Policy
|
||||
if err := s.store.DB().First(&policy, id).Error; err != nil {
|
||||
return errors.New("策略不存在")
|
||||
}
|
||||
|
||||
// 系统默认策略不可删除
|
||||
if policy.Type == "system" {
|
||||
return errors.New("系统默认策略不可删除")
|
||||
}
|
||||
|
||||
// 检查是否有关联的组网
|
||||
var count int64
|
||||
s.store.DB().Model(&model.Network{}).Where("policy_id = ?", id).Count(&count)
|
||||
if count > 0 {
|
||||
return errors.New("该策略正在被组网引用,无法删除")
|
||||
}
|
||||
|
||||
return s.store.DB().Delete(&policy).Error
|
||||
}
|
||||
|
||||
// InitializeDefaultPolicy 初始化默认策略
|
||||
func (s *PolicyService) InitializeDefaultPolicy() error {
|
||||
// 检查是否已存在默认策略
|
||||
var count int64
|
||||
s.store.DB().Model(&model.Policy{}).Where("type = ?", "system").Count(&count)
|
||||
if count > 0 {
|
||||
return nil // 已存在默认策略
|
||||
}
|
||||
|
||||
// 创建默认策略
|
||||
defaultPolicy := &model.Policy{
|
||||
Name: "系统默认策略",
|
||||
Type: "system",
|
||||
Description: "MeshRay 系统默认传输策略,包含基础的 P2P 直连和 TURN 中继功能",
|
||||
Enabled: true,
|
||||
IsDefault: true,
|
||||
LayerConfig: `{
|
||||
"tunnel": {"enabled": true},
|
||||
"obfuscation": {"enabled": false, "method": "xor"},
|
||||
"compression": {"enabled": false, "algorithm": "lz4"},
|
||||
"mux": {"enabled": false, "protocol": "yamux", "streams": 4},
|
||||
"fec": {"enabled": false, "mode": "rs", "redundancy": 20},
|
||||
"qos": {"enabled": false},
|
||||
"nat": {"enabled": true},
|
||||
"relay": {"enabled": true}
|
||||
}`,
|
||||
GlobalParams: `{
|
||||
"encryption": "chacha20poly1305",
|
||||
"ipv6_enabled": false,
|
||||
"connect_timeout": 10,
|
||||
"fallback_threshold": 2000
|
||||
}`,
|
||||
}
|
||||
|
||||
if err := s.store.DB().Create(defaultPolicy).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// RestartCoreService 重启核心服务(用于管理员操作)
|
||||
type RestartCoreService struct {
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewRestartCoreService 创建重启核心服务
|
||||
func NewRestartCoreService(logger *zap.Logger) *RestartCoreService {
|
||||
return &RestartCoreService{
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// RestartCore 重启核心服务(优雅重启)
|
||||
func (s *RestartCoreService) RestartCore() error {
|
||||
s.logger.Info("开始重启核心服务...")
|
||||
|
||||
// 1. 记录当前进程 ID
|
||||
pid := os.Getpid()
|
||||
s.logger.Info("当前进程 ID", zap.Int("pid", pid))
|
||||
|
||||
// 2. 获取当前可执行文件路径
|
||||
execPath, err := os.Executable()
|
||||
if err != nil {
|
||||
s.logger.Error("获取可执行文件路径失败", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
|
||||
// 3. 启动新进程
|
||||
cmd := exec.Command(execPath)
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||
HideWindow: true, // Windows 隐藏窗口
|
||||
CreationFlags: syscall.CREATE_NEW_PROCESS_GROUP, // Windows 创建新进程组
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
s.logger.Error("启动新进程失败", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
|
||||
s.logger.Info("新进程启动成功", zap.Int("new_pid", cmd.Process.Pid))
|
||||
|
||||
// 4. 等待一小段时间确保新进程稳定
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
// 5. 退出当前进程
|
||||
s.logger.Info("当前进程即将退出")
|
||||
os.Exit(0)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.zkcoi.com/zkcoi/meshray/internal/dnsprovider"
|
||||
"git.zkcoi.com/zkcoi/meshray/internal/model"
|
||||
"git.zkcoi.com/zkcoi/meshray/internal/store/sqlite"
|
||||
"github.com/google/uuid"
|
||||
"github.com/libdns/libdns"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ServiceService 服务管理服务
|
||||
type ServiceService struct {
|
||||
store *sqlite.Store
|
||||
}
|
||||
|
||||
// NewServiceService 创建服务服务实例
|
||||
func NewServiceService(store *sqlite.Store) *ServiceService {
|
||||
return &ServiceService{store: store}
|
||||
}
|
||||
|
||||
// ListServices 获取服务列表(支持按类型筛选)
|
||||
func (s *ServiceService) ListServices(serverType string) ([]model.Service, error) {
|
||||
var services []model.Service
|
||||
query := s.store.DB().Order("created_at desc")
|
||||
if serverType != "" {
|
||||
// 支持大小写不敏感匹配
|
||||
query = query.Where("LOWER(type) = LOWER(?)", serverType)
|
||||
}
|
||||
err := query.Find(&services).Error
|
||||
return services, err
|
||||
}
|
||||
|
||||
// GetServiceByID 根据 ID 获取服务
|
||||
func (s *ServiceService) GetServiceByID(id string) (*model.Service, error) {
|
||||
var service model.Service
|
||||
if err := s.store.DB().Where("id = ?", id).First(&service).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, errors.New("服务不存在")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &service, nil
|
||||
}
|
||||
|
||||
// CreateService 创建服务
|
||||
func (s *ServiceService) CreateService(req *model.Service) (*model.Service, error) {
|
||||
if req.Name == "" {
|
||||
return nil, errors.New("服务名称不能为空")
|
||||
}
|
||||
if req.Type == "" {
|
||||
return nil, errors.New("服务类型不能为空")
|
||||
}
|
||||
|
||||
// 强制转换为大写以保持一致性
|
||||
req.Type = strings.ToUpper(req.Type)
|
||||
|
||||
// 校验支持的类型
|
||||
allowedTypes := map[string]bool{
|
||||
"STUN": true, "TURN": true, "DDNS": true, "TUN": true, "CUSTOM": true,
|
||||
}
|
||||
if !allowedTypes[req.Type] {
|
||||
return nil, fmt.Errorf("不支持的服务类型: %s", req.Type)
|
||||
}
|
||||
|
||||
// 地址和端口校验(除 TUN 外通常需要)
|
||||
if req.Type != "TUN" {
|
||||
if req.Address == "" {
|
||||
return nil, errors.New("服务器地址不能为空")
|
||||
}
|
||||
if req.Port <= 0 || req.Port > 65535 {
|
||||
return nil, errors.New("端口号无效")
|
||||
}
|
||||
}
|
||||
|
||||
// DDNS 鉴权校验(基础设施模式)
|
||||
if req.Type == "DDNS" && req.ConfigMode == "infrastructure" {
|
||||
if req.Provider == "" {
|
||||
return nil, errors.New("请选择 DNS 服务商")
|
||||
}
|
||||
if req.Domain == "" {
|
||||
return nil, errors.New("请输入根域名")
|
||||
}
|
||||
// 根据服务商校验认证信息
|
||||
switch req.Provider {
|
||||
case "cloudflare":
|
||||
if req.Token == "" {
|
||||
return nil, errors.New("请输入 API Token")
|
||||
}
|
||||
case "aliyun":
|
||||
if req.AuthUsername == "" || req.AuthPassword == "" {
|
||||
return nil, errors.New("请输入 AccessKey ID 和 Secret")
|
||||
}
|
||||
case "tencent":
|
||||
if req.AuthUsername == "" || req.AuthPassword == "" {
|
||||
return nil, errors.New("请输入 SecretId 和 SecretKey")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DDNS 全功能模式校验
|
||||
if req.Type == "DDNS" && req.ConfigMode == "fullservice" {
|
||||
if req.DDNSConfigID == "" {
|
||||
return nil, errors.New("请选择 DDNS 配置")
|
||||
}
|
||||
if req.RecordType == "" {
|
||||
return nil, errors.New("请选择记录类型")
|
||||
}
|
||||
// 根据记录类型校验字段
|
||||
switch req.RecordType {
|
||||
case "A", "AAAA":
|
||||
if req.Subdomain == "" {
|
||||
return nil, errors.New("请输入主机记录")
|
||||
}
|
||||
if req.TargetIP == "" {
|
||||
return nil, errors.New("请输入目标 IP")
|
||||
}
|
||||
if req.Port <= 0 {
|
||||
return nil, errors.New("请输入检测端口")
|
||||
}
|
||||
case "TXT":
|
||||
if req.TXTRecordName == "" {
|
||||
return nil, errors.New("请输入 TXT 记录名称")
|
||||
}
|
||||
if req.TXTValue == "" {
|
||||
return nil, errors.New("请输入 TXT 记录值")
|
||||
}
|
||||
case "CNAME":
|
||||
if req.CNAMETarget == "" {
|
||||
return nil, errors.New("请输入目标域名")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 生成 ID
|
||||
if req.ID == "" {
|
||||
req.ID = uuid.New().String()
|
||||
}
|
||||
|
||||
// 如果是 DDNS 全功能模式,先创建 DNS 记录
|
||||
if req.Type == "DDNS" && req.ConfigMode == "fullservice" {
|
||||
// 使用事务确保原子性
|
||||
tx := s.store.DB().Begin()
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
tx.Rollback()
|
||||
}
|
||||
}()
|
||||
|
||||
// 1. 获取关联的 DDNS 配置
|
||||
var ddnsConfig model.Service
|
||||
if err := tx.Where("id = ?", req.DDNSConfigID).First(&ddnsConfig).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return nil, fmt.Errorf("获取 DDNS 配置失败:%w", err)
|
||||
}
|
||||
|
||||
// 2. 确定记录类型、名称和值
|
||||
var recordType, name, value string
|
||||
switch req.RecordType {
|
||||
case "A", "AAAA":
|
||||
recordType = req.RecordType
|
||||
name = req.Subdomain
|
||||
value = req.TargetIP
|
||||
case "TXT":
|
||||
recordType = req.RecordType
|
||||
name = req.TXTRecordName
|
||||
value = req.TXTValue
|
||||
case "CNAME":
|
||||
recordType = req.RecordType
|
||||
name = req.Subdomain
|
||||
value = req.CNAMETarget
|
||||
default:
|
||||
tx.Rollback()
|
||||
return nil, errors.New("不支持的记录类型")
|
||||
}
|
||||
|
||||
// 3. 创建 DNS Provider
|
||||
providerConfig := dnsprovider.ProviderConfig{
|
||||
Provider: dnsprovider.ProviderType(ddnsConfig.Provider),
|
||||
Domain: ddnsConfig.Domain,
|
||||
APIToken: ddnsConfig.Token,
|
||||
AccessKeyID: ddnsConfig.AuthUsername,
|
||||
AccessKeySecret: ddnsConfig.AuthPassword,
|
||||
SecretId: ddnsConfig.AuthUsername,
|
||||
SecretKey: ddnsConfig.AuthPassword,
|
||||
}
|
||||
|
||||
provider, err := dnsprovider.NewDNSProvider(providerConfig)
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
return nil, fmt.Errorf("创建 DNS Provider 失败:%w", err)
|
||||
}
|
||||
|
||||
// 4. 构建并添加 DNS 记录
|
||||
dnsRecord := &dnsprovider.DNSRecord{
|
||||
Type: dnsprovider.RecordType(recordType),
|
||||
Name: name,
|
||||
Value: value,
|
||||
TTL: req.TTL,
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err = provider.AppendRecords(ctx, ddnsConfig.Domain, []libdns.Record{dnsRecord.ToLibdnsRecord()})
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
return nil, fmt.Errorf("创建 DNS 记录失败:%w", err)
|
||||
}
|
||||
|
||||
// 5. DNS 记录创建成功,保存到数据库
|
||||
if err := tx.Create(req).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tx.Commit()
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// 其他情况直接保存
|
||||
if err := s.store.DB().Create(req).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// UpdateService 更新服务
|
||||
func (s *ServiceService) UpdateService(id string, updates map[string]interface{}) (*model.Service, error) {
|
||||
var service model.Service
|
||||
if err := s.store.DB().Where("id = ?", id).First(&service).Error; err != nil {
|
||||
return nil, errors.New("服务不存在")
|
||||
}
|
||||
|
||||
if err := s.store.DB().Model(&service).Updates(updates).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &service, nil
|
||||
}
|
||||
|
||||
// DeleteService 删除服务
|
||||
func (s *ServiceService) DeleteService(id string) error {
|
||||
var service model.Service
|
||||
if err := s.store.DB().Where("id = ?", id).First(&service).Error; err != nil {
|
||||
return errors.New("服务不存在")
|
||||
}
|
||||
return s.store.DB().Delete(&service).Error
|
||||
}
|
||||
|
||||
// TestServiceConnectivity 测试服务连通性
|
||||
func (s *ServiceService) TestServiceConnectivity(id string) (string, error) {
|
||||
var service model.Service
|
||||
if err := s.store.DB().Where("id = ?", id).First(&service).Error; err != nil {
|
||||
return "", errors.New("服务不存在")
|
||||
}
|
||||
|
||||
addr := net.JoinHostPort(service.Address, strconv.Itoa(service.Port))
|
||||
|
||||
if service.Type == "STUN" {
|
||||
// STUN 使用 UDP 测试
|
||||
conn, err := net.DialTimeout("udp", addr, 5*time.Second)
|
||||
if err != nil {
|
||||
return "unreachable", nil
|
||||
}
|
||||
conn.Close()
|
||||
return "ok", nil
|
||||
}
|
||||
|
||||
// 其他使用 TCP 测试
|
||||
conn, err := net.DialTimeout("tcp", addr, 5*time.Second)
|
||||
if err != nil {
|
||||
return "unreachable", nil
|
||||
}
|
||||
conn.Close()
|
||||
return "ok", nil
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"git.zkcoi.com/zkcoi/meshray/internal/model"
|
||||
sqlite "git.zkcoi.com/zkcoi/meshray/internal/store/sqlite"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// SettingsService 系统设置服务
|
||||
type SettingsService struct {
|
||||
store *sqlite.Store
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewSettingsService 创建系统设置服务
|
||||
func NewSettingsService(store *sqlite.Store, logger *zap.Logger) *SettingsService {
|
||||
return &SettingsService{
|
||||
store: store,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// GetSettings 获取系统设置(单例)
|
||||
func (s *SettingsService) GetSettings() (*model.SystemSetting, error) {
|
||||
var setting model.SystemSetting
|
||||
|
||||
// 尝试查找 ID=1 的记录
|
||||
result := s.store.DB().First(&setting, 1)
|
||||
if result.Error != nil {
|
||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
// 如果不存在,创建默认设置
|
||||
setting = model.SystemSetting{
|
||||
ID: 1,
|
||||
ServerIP: "",
|
||||
ServerPort: 51820,
|
||||
LogLevel: "info",
|
||||
LogFormat: "console",
|
||||
MaxBackups: 7,
|
||||
MaxAge: 30,
|
||||
Theme: "light",
|
||||
Language: "zh-CN",
|
||||
}
|
||||
if err := s.store.DB().Create(&setting).Error; err != nil {
|
||||
return nil, fmt.Errorf("创建默认设置失败:%w", err)
|
||||
}
|
||||
s.logger.Info("已创建默认系统设置")
|
||||
return &setting, nil
|
||||
}
|
||||
return nil, fmt.Errorf("查询设置失败:%w", result.Error)
|
||||
}
|
||||
|
||||
return &setting, nil
|
||||
}
|
||||
|
||||
// UpdateSettings 更新系统设置
|
||||
func (s *SettingsService) UpdateSettings(updates map[string]interface{}) (*model.SystemSetting, error) {
|
||||
// 先获取现有设置
|
||||
setting, err := s.GetSettings()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// JSON 序列化 updates,确保数据有效
|
||||
data, err := json.Marshal(updates)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("序列化更新数据失败:%w", err)
|
||||
}
|
||||
|
||||
// 反序列化到临时对象,过滤无效字段
|
||||
var validUpdates map[string]interface{}
|
||||
if err := json.Unmarshal(data, &validUpdates); err != nil {
|
||||
return nil, fmt.Errorf("解析更新数据失败:%w", err)
|
||||
}
|
||||
|
||||
// 移除敏感字段和不可变字段
|
||||
delete(validUpdates, "id")
|
||||
delete(validUpdates, "created_at")
|
||||
delete(validUpdates, "updated_at")
|
||||
|
||||
// 构建 GORM 的 Updates map
|
||||
updatesMap := make(map[string]interface{})
|
||||
for key, value := range validUpdates {
|
||||
// 驼峰转蛇形转换(可选,这里直接使用前端传来的键名)
|
||||
updatesMap[key] = value
|
||||
}
|
||||
|
||||
// 执行更新
|
||||
if err := s.store.DB().Model(&setting).Updates(updatesMap).Error; err != nil {
|
||||
return nil, fmt.Errorf("更新设置失败:%w", err)
|
||||
}
|
||||
|
||||
s.logger.Info("系统设置已更新", zap.Any("updates", updates))
|
||||
|
||||
// 返回最新数据
|
||||
return s.GetSettings()
|
||||
}
|
||||
|
||||
// ResetSettings 重置为默认设置
|
||||
func (s *SettingsService) ResetSettings() (*model.SystemSetting, error) {
|
||||
_, err := s.GetSettings()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defaultSetting := model.SystemSetting{
|
||||
ID: 1,
|
||||
ServerIP: "",
|
||||
ServerPort: 51820,
|
||||
LogLevel: "info",
|
||||
LogFormat: "console",
|
||||
MaxBackups: 7,
|
||||
MaxAge: 30,
|
||||
Theme: "light",
|
||||
Language: "zh-CN",
|
||||
}
|
||||
|
||||
if err := s.store.DB().Save(&defaultSetting).Error; err != nil {
|
||||
return nil, fmt.Errorf("重置设置失败:%w", err)
|
||||
}
|
||||
|
||||
s.logger.Info("系统设置已重置为默认值")
|
||||
return &defaultSetting, nil
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"git.zkcoi.com/zkcoi/meshray/internal/model"
|
||||
"git.zkcoi.com/zkcoi/meshray/internal/store/sqlite"
|
||||
"go.uber.org/zap"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// SystemConfigService 系统配置服务
|
||||
type SystemConfigService struct {
|
||||
store *sqlite.Store
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewSystemConfigService 创建系统配置服务
|
||||
func NewSystemConfigService(store *sqlite.Store, logger *zap.Logger) *SystemConfigService {
|
||||
return &SystemConfigService{
|
||||
store: store,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// GetWGMode 获取当前 WG 运行模式
|
||||
func (s *SystemConfigService) GetWGMode() (string, error) {
|
||||
config, err := s.getConfig("wg_mode")
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
// 如果不存在,返回默认值 "auto"
|
||||
return "auto", nil
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
return config.Value, nil
|
||||
}
|
||||
|
||||
// SetWGMode 设置 WG 运行模式(需要重启 MeshRay 才能生效)
|
||||
func (s *SystemConfigService) SetWGMode(mode string) error {
|
||||
// 验证模式值
|
||||
validModes := map[string]bool{
|
||||
"auto": true,
|
||||
"kernel": true,
|
||||
"userspace": true,
|
||||
}
|
||||
if !validModes[mode] {
|
||||
return errors.New("无效的 WG 模式,仅支持:auto, kernel, userspace")
|
||||
}
|
||||
|
||||
return s.setConfig("wg_mode", mode)
|
||||
}
|
||||
|
||||
// GetConfig 获取任意配置项
|
||||
func (s *SystemConfigService) GetConfig(key string) (string, error) {
|
||||
config, err := s.getConfig(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return config.Value, nil
|
||||
}
|
||||
|
||||
// SetConfig 设置任意配置项
|
||||
func (s *SystemConfigService) SetConfig(key, value string) error {
|
||||
return s.setConfig(key, value)
|
||||
}
|
||||
|
||||
// getConfig 内部方法:获取配置
|
||||
func (s *SystemConfigService) getConfig(key string) (*model.SystemConfig, error) {
|
||||
var config model.SystemConfig
|
||||
err := s.store.DB().Where("key = ?", key).First(&config).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
// setConfig 内部方法:设置配置
|
||||
func (s *SystemConfigService) setConfig(key, value string) error {
|
||||
var config model.SystemConfig
|
||||
err := s.store.DB().Where("key = ?", key).First(&config).Error
|
||||
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
// 创建新配置
|
||||
config = model.SystemConfig{
|
||||
Key: key,
|
||||
Value: value,
|
||||
}
|
||||
return s.store.DB().Create(&config).Error
|
||||
}
|
||||
|
||||
// 更新现有配置
|
||||
config.Value = value
|
||||
return s.store.DB().Save(&config).Error
|
||||
}
|
||||
|
||||
// ChangePassword 修改密码(P1)
|
||||
func (s *SystemConfigService) ChangePassword(userID uint, oldPassword, newPassword string) error {
|
||||
// 获取用户
|
||||
var user model.User
|
||||
if err := s.store.DB().First(&user, userID).Error; err != nil {
|
||||
return errors.New("用户不存在")
|
||||
}
|
||||
|
||||
// 验证旧密码
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(oldPassword)); err != nil {
|
||||
return errors.New("原密码错误")
|
||||
}
|
||||
|
||||
// 加密新密码
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
s.logger.Error("加密新密码失败", zap.Error(err))
|
||||
return errors.New("密码加密失败")
|
||||
}
|
||||
|
||||
// 更新密码
|
||||
return s.store.DB().Model(&user).Update("password_hash", string(hashedPassword)).Error
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
|
||||
"git.zkcoi.com/zkcoi/meshray/internal/model"
|
||||
"git.zkcoi.com/zkcoi/meshray/internal/store/sqlite"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// 字符集用于生成随机密码(保留用于特殊场景)
|
||||
const passwordChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*"
|
||||
|
||||
// generateRandomPassword 使用 crypto/rand 生成安全的随机密码
|
||||
func generateRandomPassword(length int) string {
|
||||
b := make([]byte, length)
|
||||
_, err := rand.Read(b)
|
||||
if err != nil {
|
||||
// 极端情况下回退到简单方案(几乎不会发生)
|
||||
return "REPLACE_WITH_SECURE_PASSWORD"
|
||||
}
|
||||
// 使用 Base64 编码,确保包含各种字符
|
||||
encoded := base64.StdEncoding.EncodeToString(b)
|
||||
// 截取所需长度(Base64 编码后长度为 4/3 倍)
|
||||
if len(encoded) >= length {
|
||||
return encoded[:length]
|
||||
}
|
||||
return encoded
|
||||
}
|
||||
|
||||
// GenerateRandomPassword 生成随机密码(公开函数)
|
||||
func GenerateRandomPassword(length int) string {
|
||||
return generateRandomPassword(length)
|
||||
}
|
||||
|
||||
// UserService 用户服务
|
||||
type UserService struct {
|
||||
store *sqlite.Store
|
||||
}
|
||||
|
||||
// NewUserService 创建用户服务实例
|
||||
func NewUserService(store *sqlite.Store) *UserService {
|
||||
return &UserService{store: store}
|
||||
}
|
||||
|
||||
// InitializeAdmin 初始化管理员账户(首次启动时调用)
|
||||
func (s *UserService) InitializeAdmin() (username, password string, err error) {
|
||||
// 检查是否已存在 admin 用户
|
||||
var existingUser model.User
|
||||
if err := s.store.DB().Where("role = ?", "admin").First(&existingUser).Error; err == nil {
|
||||
// 已存在,返回当前用户名(密码不返回)
|
||||
return existingUser.Username, "", nil
|
||||
}
|
||||
|
||||
// 生成随机密码
|
||||
randomPassword := generateRandomPassword(16)
|
||||
|
||||
// 密码加密
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(randomPassword), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
// 创建 admin 用户
|
||||
adminUser := &model.User{
|
||||
Username: "admin",
|
||||
PasswordHash: string(hashedPassword),
|
||||
Email: "",
|
||||
Role: "admin",
|
||||
Status: "active",
|
||||
}
|
||||
|
||||
if err := s.store.DB().Create(adminUser).Error; err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
return "admin", randomPassword, nil
|
||||
}
|
||||
|
||||
// Authenticate 验证用户登录
|
||||
func (s *UserService) Authenticate(username, password string) (*model.User, error) {
|
||||
var user model.User
|
||||
if err := s.store.DB().Where("username = ?", username).First(&user).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, errors.New("用户不存在")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 验证密码
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
|
||||
return nil, errors.New("密码错误")
|
||||
}
|
||||
|
||||
// 检查用户状态
|
||||
if user.Status != "active" {
|
||||
return nil, errors.New("账户已被禁用")
|
||||
}
|
||||
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// ChangePasswordRequest 修改密码请求
|
||||
type ChangePasswordRequest struct {
|
||||
OldPassword string `json:"old_password"`
|
||||
NewPassword string `json:"new_password"`
|
||||
}
|
||||
|
||||
// ChangePassword 修改用户密码
|
||||
func (s *UserService) ChangePassword(userID uint, req *ChangePasswordRequest) error {
|
||||
// 1. 查询用户
|
||||
var user model.User
|
||||
if err := s.store.DB().First(&user, userID).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return errors.New("用户不存在")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// 2. 验证旧密码
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.OldPassword)); err != nil {
|
||||
return errors.New("原密码错误")
|
||||
}
|
||||
|
||||
// 3. 验证新密码强度
|
||||
if len(req.NewPassword) < 6 {
|
||||
return errors.New("密码长度不能少于 6 位")
|
||||
}
|
||||
|
||||
// 4. 加密新密码
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 5. 更新密码
|
||||
user.PasswordHash = string(hashedPassword)
|
||||
if err := s.store.DB().Save(&user).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetUserByID 根据 ID 获取用户
|
||||
func (s *UserService) GetUserByID(userID uint) (*model.User, error) {
|
||||
var user model.User
|
||||
if err := s.store.DB().First(&user, userID).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// UpdateUser 更新用户信息
|
||||
func (s *UserService) UpdateUser(userID uint, updates map[string]interface{}) (*model.User, error) {
|
||||
var user model.User
|
||||
if err := s.store.DB().First(&user, userID).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := s.store.DB().Model(&user).Updates(updates).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// UpdateAdminProfile 更新管理员资料(仅允许 admin 用户调用)
|
||||
func (s *UserService) UpdateAdminProfile(username, email, password string) error {
|
||||
var user model.User
|
||||
if err := s.store.DB().Where("role = ?", "admin").First(&user).Error; err != nil {
|
||||
return errors.New("管理员账户不存在")
|
||||
}
|
||||
|
||||
updates := make(map[string]interface{})
|
||||
if username != "" && username != user.Username {
|
||||
// 检查新用户名是否已被使用
|
||||
var existing model.User
|
||||
if err := s.store.DB().Where("username = ? AND role = ?", username, "admin").First(&existing).Error; err == nil {
|
||||
return errors.New("用户名已存在")
|
||||
}
|
||||
updates["username"] = username
|
||||
}
|
||||
|
||||
if email != "" && email != user.Email {
|
||||
updates["email"] = email
|
||||
}
|
||||
|
||||
if password != "" {
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
updates["password_hash"] = string(hashedPassword)
|
||||
}
|
||||
|
||||
if len(updates) == 0 {
|
||||
return nil // 没有需要更新的
|
||||
}
|
||||
|
||||
return s.store.DB().Model(&user).Updates(updates).Error
|
||||
}
|
||||
|
||||
// ResetAdminPassword 重置管理员密码(命令行工具使用)
|
||||
func (s *UserService) ResetAdminPassword(newPassword string) error {
|
||||
var user model.User
|
||||
if err := s.store.DB().Where("role = ?", "admin").First(&user).Error; err != nil {
|
||||
return errors.New("管理员账户不存在")
|
||||
}
|
||||
|
||||
// 加密新密码
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.store.DB().Model(&user).Update("password_hash", string(hashedPassword)).Error
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// Package store defines the data persistence layer interface.
|
||||
package store
|
||||
@@ -0,0 +1,102 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"git.zkcoi.com/zkcoi/meshray/internal/model"
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Store SQLite 存储实现(基于 GORM + glebarez/sqlite)
|
||||
type Store struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// New 创建 Store 实例
|
||||
func New(dbPath string) (*Store, error) {
|
||||
// 使用 glebarez/sqlite(纯Go 实现,无需 CGO)
|
||||
// 这是 modernc.org/sqlite 的 GORM 适配层
|
||||
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("初始化数据库失败:%w", err)
|
||||
}
|
||||
|
||||
store := &Store{db: db}
|
||||
|
||||
// 自动迁移所有表
|
||||
if err := store.AutoMigrate(); err != nil {
|
||||
return nil, fmt.Errorf("数据库迁移失败:%w", err)
|
||||
}
|
||||
|
||||
// 初始化默认系统设置
|
||||
if err := store.initDefaultSettings(); err != nil {
|
||||
return nil, fmt.Errorf("初始化系统设置失败:%w", err)
|
||||
}
|
||||
|
||||
return store, nil
|
||||
}
|
||||
|
||||
// AutoMigrate 自动创建所有数据表
|
||||
func (s *Store) AutoMigrate() error {
|
||||
return s.db.AutoMigrate(
|
||||
&model.Network{},
|
||||
&model.Device{},
|
||||
&model.Policy{},
|
||||
&model.Service{},
|
||||
&model.MeshSeed{},
|
||||
&model.PendingJoin{},
|
||||
&model.AlertRule{},
|
||||
&model.AuditLog{},
|
||||
&model.User{},
|
||||
&model.SystemConfig{},
|
||||
&model.DDNSConfig{},
|
||||
&model.NetworkMember{},
|
||||
&model.SecurityKey{}, // 新增:安全密钥表
|
||||
&model.SystemSetting{}, // 新增:系统设置表
|
||||
&model.ExternalService{}, // 新增:外部服务表
|
||||
&model.DDNSProvider{}, // 新增:DDNS 服务商配置
|
||||
&model.DDNSUsage{}, // 新增:DDNS 用途定义
|
||||
&model.NetworkDDNSBinding{}, // 新增:网络 DDNS 绑定
|
||||
&model.Notification{}, // 新增:通知表
|
||||
)
|
||||
}
|
||||
|
||||
// initDefaultSettings 初始化默认系统设置
|
||||
func (s *Store) initDefaultSettings() error {
|
||||
// 检查是否已有系统设置
|
||||
var count int64
|
||||
if err := s.db.Model(&model.SystemSetting{}).Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 如果没有记录,创建默认设置
|
||||
if count == 0 {
|
||||
return s.db.Create(&model.SystemSetting{
|
||||
ID: 1,
|
||||
ServerPort: 51820,
|
||||
LogLevel: "info",
|
||||
LogFormat: "console",
|
||||
MaxBackups: 7,
|
||||
MaxAge: 30,
|
||||
Theme: "light",
|
||||
Language: "zh-CN",
|
||||
}).Error
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DB 获取 GORM 数据库连接
|
||||
func (s *Store) DB() *gorm.DB {
|
||||
return s.db
|
||||
}
|
||||
|
||||
// Close 关闭数据库连接
|
||||
func (s *Store) Close() error {
|
||||
sqlDB, err := s.db.DB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return sqlDB.Close()
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
@@ -0,0 +1,226 @@
|
||||
// Windows系统托盘管理
|
||||
//go:build windows
|
||||
// +build windows
|
||||
|
||||
package tray
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
|
||||
"github.com/getlantern/systray"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
//go:embed favicon.ico
|
||||
var trayIcon []byte
|
||||
|
||||
// TrayManager 托盘管理器
|
||||
type TrayManager struct {
|
||||
logger *zap.Logger
|
||||
webPort int
|
||||
onExit func()
|
||||
mQuit *systray.MenuItem
|
||||
mOpenWeb *systray.MenuItem
|
||||
mStatus *systray.MenuItem
|
||||
mRestart *systray.MenuItem
|
||||
}
|
||||
|
||||
// NewTrayManager 创建托盘管理器
|
||||
func NewTrayManager(logger *zap.Logger, webPort int, onExit func()) *TrayManager {
|
||||
return &TrayManager{
|
||||
logger: logger,
|
||||
webPort: webPort,
|
||||
onExit: onExit,
|
||||
}
|
||||
}
|
||||
|
||||
// Run 运行托盘(阻塞)
|
||||
func (t *TrayManager) Run() {
|
||||
systray.Run(t.onReady, t.onExit)
|
||||
}
|
||||
|
||||
// onReady 托盘初始化
|
||||
func (t *TrayManager) onReady() {
|
||||
// 设置托盘图标和提示
|
||||
systray.SetIcon(trayIcon)
|
||||
systray.SetTooltip("MeshRay - 智能组网工具")
|
||||
|
||||
// 添加菜单项
|
||||
t.mStatus = systray.AddMenuItem("🟢 状态:运行中", "MeshRay运行状态")
|
||||
t.mStatus.Disable()
|
||||
|
||||
systray.AddSeparator()
|
||||
|
||||
t.mOpenWeb = systray.AddMenuItem("🌐 打开管理界面", "打开Web管理界面")
|
||||
_ = systray.AddMenuItem("ℹ️ 关于", "关于MeshRay")
|
||||
|
||||
systray.AddSeparator()
|
||||
|
||||
_ = systray.AddMenuItem("❓ 帮助", "查看帮助")
|
||||
_ = systray.AddMenuItem("🔄 检查更新", "检查新版本")
|
||||
|
||||
systray.AddSeparator()
|
||||
|
||||
t.mRestart = systray.AddMenuItem("🔁 重启服务", "重启MeshRay服务")
|
||||
t.mQuit = systray.AddMenuItem("❌ 退出", "退出MeshRay")
|
||||
|
||||
// 处理菜单点击
|
||||
go t.handleMenuEvents()
|
||||
}
|
||||
|
||||
// handleMenuEvents 处理菜单事件
|
||||
func (t *TrayManager) handleMenuEvents() {
|
||||
for {
|
||||
select {
|
||||
case <-t.mOpenWeb.ClickedCh:
|
||||
t.openWebInterface()
|
||||
|
||||
case <-t.mRestart.ClickedCh:
|
||||
t.restartService()
|
||||
|
||||
case <-t.mQuit.ClickedCh:
|
||||
t.quit()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// openWebInterface 打开Web管理界面
|
||||
func (t *TrayManager) openWebInterface() {
|
||||
url := fmt.Sprintf("http://localhost:%d", t.webPort)
|
||||
t.logger.Info("打开Web管理界面", zap.String("url", url))
|
||||
|
||||
var cmd *exec.Cmd
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
cmd = exec.Command("cmd", "/c", "start", url)
|
||||
case "darwin":
|
||||
cmd = exec.Command("open", url)
|
||||
default:
|
||||
cmd = exec.Command("xdg-open", url)
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.logger.Error("打开浏览器失败", zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// restartService 重启服务
|
||||
func (t *TrayManager) restartService() {
|
||||
t.logger.Info("重启服务...")
|
||||
// ✅ P3 阶段 - 简单重启:先退出,然后由外部监控进程重启
|
||||
// 完整实现需要 Windows 服务控制或守护进程
|
||||
t.quit()
|
||||
}
|
||||
|
||||
// quit 退出程序
|
||||
func (t *TrayManager) quit() {
|
||||
t.logger.Info("退出MeshRay...")
|
||||
systray.Quit()
|
||||
if t.onExit != nil {
|
||||
t.onExit()
|
||||
}
|
||||
}
|
||||
|
||||
// updateStatus 更新状态
|
||||
func (t *TrayManager) updateStatus() {
|
||||
// ✅ P3 阶段 - 暂不实现动态状态更新
|
||||
// 未来可以检查服务运行状态,更新托盘菜单(如:运行中/已停止)
|
||||
}
|
||||
|
||||
// MeshRayIcon 托盘图标(ICO格式,16x16)
|
||||
// 简单的 "MR" 文字图标
|
||||
var MeshRayIcon = []byte{
|
||||
// ICO 头部
|
||||
0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x10, 0x10,
|
||||
0x00, 0x00, 0x01, 0x00, 0x20, 0x00, 0x68, 0x04,
|
||||
0x00, 0x00, 0x16, 0x00, 0x00, 0x00, 0x28, 0x00,
|
||||
0x00, 0x00, 0x10, 0x00, 0x00, 0x00, 0x20, 0x00,
|
||||
0x00, 0x00, 0x01, 0x00, 0x20, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
// 图标数据 - 蓝色背景上的白色 "MR" 文字
|
||||
// 第1行
|
||||
0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff,
|
||||
0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff,
|
||||
0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff,
|
||||
0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff,
|
||||
// 第2行
|
||||
0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
|
||||
0xff, 0xff, 0xff, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
|
||||
0xff, 0xff, 0xff, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff,
|
||||
0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff,
|
||||
// 第3行
|
||||
0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0xff, 0xff, 0xff, 0xff, 0x1b, 0x6b, 0xa2, 0xff,
|
||||
0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0xff, 0xff, 0xff, 0xff, 0x1b, 0x6b, 0xa2, 0xff,
|
||||
0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff,
|
||||
0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff,
|
||||
// 第4行
|
||||
0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0xff, 0xff, 0xff, 0xff, 0x1b, 0x6b, 0xa2, 0xff,
|
||||
0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0xff, 0xff, 0xff, 0xff, 0x1b, 0x6b, 0xa2, 0xff,
|
||||
0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff,
|
||||
0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff,
|
||||
// 第5行
|
||||
0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
|
||||
0xff, 0xff, 0xff, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0xff, 0xff, 0xff, 0xff, 0x1b, 0x6b, 0xa2, 0xff,
|
||||
0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff,
|
||||
0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff,
|
||||
// 第6行
|
||||
0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0xff, 0xff, 0xff, 0xff, 0x1b, 0x6b, 0xa2, 0xff,
|
||||
0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff,
|
||||
0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff,
|
||||
0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff,
|
||||
// 第7行
|
||||
0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0xff, 0xff, 0xff, 0xff, 0x1b, 0x6b, 0xa2, 0xff,
|
||||
0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff,
|
||||
0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff,
|
||||
0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff,
|
||||
// 第8行
|
||||
0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0xff, 0xff, 0xff, 0xff, 0x1b, 0x6b, 0xa2, 0xff,
|
||||
0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff,
|
||||
0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff,
|
||||
0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff,
|
||||
// 第9行
|
||||
0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0xff, 0xff, 0xff, 0xff, 0x1b, 0x6b, 0xa2, 0xff,
|
||||
0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff,
|
||||
0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff,
|
||||
0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff,
|
||||
// 第10行
|
||||
0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
|
||||
0xff, 0xff, 0xff, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
|
||||
0xff, 0xff, 0xff, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff,
|
||||
0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff,
|
||||
// 第11行
|
||||
0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff,
|
||||
0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff,
|
||||
0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff,
|
||||
0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff,
|
||||
// 第12行
|
||||
0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff,
|
||||
0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff,
|
||||
0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff,
|
||||
0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff,
|
||||
// 第13行
|
||||
0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff,
|
||||
0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff,
|
||||
0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff,
|
||||
0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff,
|
||||
// 第14行
|
||||
0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff,
|
||||
0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff,
|
||||
0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff,
|
||||
0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff,
|
||||
// 第15行
|
||||
0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff,
|
||||
0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff,
|
||||
0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff,
|
||||
0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff,
|
||||
// 第16行
|
||||
0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff,
|
||||
0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff,
|
||||
0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff,
|
||||
0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff, 0x1b, 0x6b, 0xa2, 0xff,
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
//go:build !windows
|
||||
// +build !windows
|
||||
|
||||
package tray
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// TrayManager 托盘管理器(非Windows平台实现)
|
||||
type TrayManager struct {
|
||||
logger *zap.Logger
|
||||
webPort int
|
||||
onExit func()
|
||||
}
|
||||
|
||||
// NewTrayManager 创建托盘管理器
|
||||
func NewTrayManager(logger *zap.Logger, webPort int, onExit func()) *TrayManager {
|
||||
return &TrayManager{
|
||||
logger: logger,
|
||||
webPort: webPort,
|
||||
onExit: onExit,
|
||||
}
|
||||
}
|
||||
|
||||
// Run 运行托盘(非Windows平台显示提示信息并阻塞)
|
||||
func (t *TrayManager) Run() {
|
||||
t.logger.Info("非Windows系统,跳过托盘初始化")
|
||||
|
||||
// 显示启动信息
|
||||
fmt.Println("")
|
||||
fmt.Println("========================================")
|
||||
fmt.Println("🌐 MeshRay 服务已启动")
|
||||
fmt.Println("========================================")
|
||||
fmt.Printf("📍 Web 管理界面: http://localhost:%d\n", t.webPort)
|
||||
fmt.Println("💡 提示: 使用 Ctrl+C 停止服务")
|
||||
fmt.Println("========================================")
|
||||
fmt.Println("")
|
||||
|
||||
// 等待退出信号
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
<-sigChan
|
||||
|
||||
t.logger.Info("收到退出信号,正在关闭...")
|
||||
if t.onExit != nil {
|
||||
t.onExit()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user