Initial commit

This commit is contained in:
2026-06-30 15:14:37 +08:00
commit 15dab96872
311 changed files with 95639 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
// Package api provides HTTP API handlers using Gin framework.
package api
+131
View File
@@ -0,0 +1,131 @@
package dto
import (
"fmt"
"git.zkcoi.com/zkcoi/meshray/internal/model"
)
// NetworkResponse 网络响应 DTOID 为字符串格式,避免 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 设备响应 DTOID 为字符串格式,避免 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 策略响应 DTOID 为字符串格式)
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
}
+195
View File
@@ -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": "核心服务正在重启",
})
}
+376
View File
@@ -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)
// 获取本机 IPIPv4 + 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
}
+141
View File
@@ -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": "同步成功",
})
}
+133
View File
@@ -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 ""
}
+313
View File
@@ -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
}
+263
View File
@@ -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))
}
+57
View File
@@ -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": "检测成功",
})
}
+549
View File
@@ -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,
},
})
}
+205
View File
@@ -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")
}
+126
View File
@@ -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": "策略已删除"})
}
+190
View File
@@ -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})
}
+65
View File
@@ -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,
})
}
+177
View File
@@ -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": "核心服务正在重启..."})
}
+157
View File
@@ -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
}
}
}
+327
View File
@@ -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("内部错误")
+532
View File
@@ -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.htmlSPA 路由支持)
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
}