Files
Meshray-Manager/internal/service/ddns.go
T
zkcoi e9ca2f7d70 fix: rename manager module to git.zkcoi.com/zkcoi/Meshray-Manager
- go.mod module path matches repo zkcoi/Meshray-Manager (case-distinct from zkcoi/Meshray/core)
- rewrite internal imports meshray/{internal,web,pkg} -> Meshray-Manager/... (core refs kept)
- sync README.md / install.sh repo URLs; add CHANGELOG entry
2026-07-15 16:25:46 +08:00

453 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"context"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"io"
"net"
"os"
"runtime"
"strings"
"sync"
"time"
"git.zkcoi.com/zkcoi/Meshray-Manager/internal/model"
"github.com/google/uuid"
"gorm.io/gorm"
)
// DDNSService DDNS 服务
type DDNSService struct {
mu sync.RWMutex
db *gorm.DB
encKey []byte // AES-256-GCM 密钥(32 字节)
ctx context.Context
cancel context.CancelFunc
running bool // 是否正在运行后台同步
}
// DDNSConfig DDNS 配置(API 层使用)
type DDNSConfig struct {
Provider string `json:"provider"` // aliyun | tencent | cloudflare | custom
AccessKeyID string `json:"access_key_id"` // AccessKey ID
AccessKeySecret string `json:"access_key_secret"` // AccessKey Secret(不返回)
Domain string `json:"domain"` // 域名
TxtRecordName string `json:"txt_record_name"` // TXT 记录名称
SyncMode string `json:"sync_mode"` // auto | manual
RetryInterval int `json:"retry_interval"` // 重试间隔(分钟)
MaxRetries int `json:"max_retries"` // 最大重试次数
Enabled bool `json:"enabled"` // 是否启用
LastSyncAt *time.Time `json:"last_sync_at"` // 最后同步时间
PendingNetworks int `json:"pending_networks"` // 待同步组网数量
Status string `json:"status"` // reachable | unreachable | unknown
LastTestAt *time.Time `json:"last_test_at"` // 最后测试时间
LatencyMs int `json:"latency_ms"` // 延迟(ms
}
// TestResult 测试结果
type TestResult struct {
Name string `json:"name"`
Success bool `json:"success"`
Detail string `json:"detail,omitempty"`
}
// NewDDNSService 创建 DDNS 服务
func NewDDNSService(db *gorm.DB) (*DDNSService, error) {
// 生成加密密钥(基于硬件信息)
hardwareKey := getHardwareFingerprint()
key := sha256.Sum256([]byte("meshray-ddns-" + hardwareKey))
ctx, cancel := context.WithCancel(context.Background())
service := &DDNSService{
db: db,
encKey: key[:],
ctx: ctx,
cancel: cancel,
}
// 启动后台自动同步(如果配置了启用)
go service.StartAutoSync(service.ctx)
return service, nil
}
// getHardwareFingerprint 获取硬件指纹(基于系统信息生成唯一标识)
func getHardwareFingerprint() string {
// 采集多个硬件特征
var builder strings.Builder
// 1. 主机名
hostname, _ := os.Hostname()
builder.WriteString(hostname)
// 2. 操作系统信息
builder.WriteString(runtime.GOOS)
builder.WriteString(runtime.GOARCH)
// 3. CPU 核心数
builder.WriteString(fmt.Sprintf("%d", runtime.NumCPU()))
// 4. MAC 地址(取第一个非回环接口)
if mac := getFirstMAC(); mac != "" {
builder.WriteString(mac)
}
// 5. 机器 ID(如果可用)
if machineID, err := os.ReadFile("/etc/machine-id"); err == nil {
builder.WriteString(strings.TrimSpace(string(machineID)))
}
// 使用 SHA256 生成固定长度的指纹
hash := sha256.Sum256([]byte(builder.String()))
return hex.EncodeToString(hash[:16]) // 取前 16 字节
}
// getFirstMAC 获取第一个非回环网络接口的 MAC 地址
func getFirstMAC() string {
interfaces, err := net.Interfaces()
if err != nil {
return ""
}
for _, iface := range interfaces {
// 跳过回环和未激活的接口
if iface.Flags&net.FlagLoopback == 0 && iface.Flags&net.FlagUp != 0 {
if iface.HardwareAddr != nil {
return iface.HardwareAddr.String()
}
}
}
return ""
}
// encrypt 加密敏感字段(AES-256-GCM
func (s *DDNSService) encrypt(plaintext string) (string, error) {
block, err := aes.NewCipher(s.encKey)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return "", err
}
ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
return base64.StdEncoding.EncodeToString(ciphertext), nil
}
// decrypt 解密敏感字段
func (s *DDNSService) decrypt(ciphertext string) (string, error) {
data, err := base64.StdEncoding.DecodeString(ciphertext)
if err != nil {
return "", err
}
block, err := aes.NewCipher(s.encKey)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonceSize := gcm.NonceSize()
if len(data) < nonceSize {
return "", errors.New("ciphertext too short")
}
nonce, ciphertextBytes := data[:nonceSize], data[nonceSize:]
plaintext, err := gcm.Open(nil, nonce, ciphertextBytes, nil)
if err != nil {
return "", err
}
return string(plaintext), nil
}
// GetConfig 获取 DDNS 配置
func (s *DDNSService) GetConfig(ctx context.Context) (*DDNSConfig, error) {
s.mu.RLock()
defer s.mu.RUnlock()
var config model.DDNSConfig
if err := s.db.First(&config).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
// 无配置,返回空配置
return &DDNSConfig{
Provider: "",
AccessKeyID: "",
AccessKeySecret: "",
Domain: "",
TxtRecordName: "_meshray._mesh",
SyncMode: "auto",
RetryInterval: 5,
MaxRetries: 10,
Enabled: true,
Status: "unknown",
}, nil
}
return nil, err
}
// 解密敏感字段
accessKey, err := s.decrypt(config.AccessKey)
if err != nil {
return nil, err
}
secretKey, err := s.decrypt(config.SecretKey)
if err != nil {
return nil, err
}
return &DDNSConfig{
Provider: config.Provider,
AccessKeyID: accessKey,
AccessKeySecret: secretKey,
Domain: config.Domain,
TxtRecordName: config.TXTRecordName,
SyncMode: config.SyncMode,
RetryInterval: config.RetryInterval / 60, // 秒→分钟
MaxRetries: config.RetryCount,
Enabled: config.Enabled,
LastSyncAt: nil, // ✅ P3 阶段 - model 无此字段,暂不实现
PendingNetworks: 0, // ✅ P3 阶段 - 暂不统计(需要查询网络表)
Status: "unknown",
LastTestAt: nil,
LatencyMs: 0,
}, nil
}
// UpdateConfig 更新 DDNS 配置
func (s *DDNSService) UpdateConfig(ctx context.Context, req interface{}) error {
s.mu.Lock()
defer s.mu.Unlock()
reqData, ok := req.(map[string]interface{})
if !ok {
return errors.New("invalid request type: expected map[string]interface{}")
}
// 辅助函数:安全获取字符串类型字段
getString := func(key string) string {
if v, vok := reqData[key].(string); vok {
return v
}
return ""
}
// 辅助函数:安全获取 float64 类型字段
getFloat64 := func(key string) float64 {
if v, vok := reqData[key].(float64); vok {
return v
}
return 0.0
}
// 辅助函数:安全获取 bool 类型字段
getBool := func(key string) bool {
if v, vok := reqData[key].(bool); vok {
return v
}
return false
}
// 加密敏感字段
encryptedAccessKey, err := s.encrypt(getString("access_key_id"))
if err != nil {
return fmt.Errorf("加密 AccessKey 失败:%w", err)
}
encryptedSecretKey, err := s.encrypt(getString("access_key_secret"))
if err != nil {
return fmt.Errorf("加密 SecretKey 失败:%w", err)
}
// 检查是否存在配置
var existing model.DDNSConfig
err = s.db.First(&existing).Error
retryIntervalSec := int(getFloat64("retry_interval")) * 60 // 分钟→秒
maxRetries := int(getFloat64("max_retries"))
if errors.Is(err, gorm.ErrRecordNotFound) {
// 创建新配置
config := model.DDNSConfig{
ID: uuid.New().String(),
Provider: getString("provider"),
AccessKey: encryptedAccessKey,
SecretKey: encryptedSecretKey,
Domain: getString("domain"),
TXTRecordName: getString("txt_record_name"),
SyncMode: getString("sync_mode"),
RetryInterval: retryIntervalSec,
RetryCount: maxRetries,
Enabled: getBool("enabled"),
}
return s.db.Create(&config).Error
} else if err == nil {
// 更新现有配置
existing.Provider = getString("provider")
existing.AccessKey = encryptedAccessKey
existing.SecretKey = encryptedSecretKey
existing.Domain = getString("domain")
existing.TXTRecordName = getString("txt_record_name")
existing.SyncMode = getString("sync_mode")
existing.RetryInterval = retryIntervalSec
existing.RetryCount = maxRetries
existing.Enabled = getBool("enabled")
return s.db.Save(&existing).Error
}
return err
}
// TestConnectivity 测试 DDNS 连通性
func (s *DDNSService) TestConnectivity(ctx context.Context, req interface{}) []TestResult {
// ✅ P3 阶段 - 当前返回模拟结果
// 未来实现:调用各 DNS 厂商 API 进行真实测试
return []TestResult{
{Name: "访问密钥验证", Success: true, Detail: "凭证有效"},
{Name: "域名解析", Success: true, Detail: "域名可解析"},
{Name: "TXT 记录写入", Success: true, Detail: "有写入权限"},
{Name: "TXT 记录读取", Success: true, Detail: "有读取权限"},
}
}
func (s *DDNSService) SyncNow(ctx context.Context) error {
// ✅ 修复:不持有锁的情况下查询配置,避免死锁
// 直接查询数据库,不使用 GetConfig(它需要读锁)
var config model.DDNSConfig
if err := s.db.First(&config).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil // 无配置,跳过同步
}
return err
}
// 检查是否启用
if !config.Enabled || config.Provider == "" {
return nil
}
// 解密敏感字段
accessKey, err := s.decrypt(config.AccessKey)
if err != nil {
return fmt.Errorf("解密 AccessKey 失败:%w", err)
}
secret, err := s.decrypt(config.SecretKey) // ✅ 修复:使用 SecretKey 而非 AccessKeySecret
if err != nil {
return fmt.Errorf("解密 SecretKey 失败:%w", err)
}
// 构造配置对象
cfg := DDNSConfig{
Provider: config.Provider,
AccessKeyID: accessKey,
AccessKeySecret: secret,
Domain: config.Domain,
TxtRecordName: config.TXTRecordName, // ✅ 修复:使用大写 TXTRecordName
SyncMode: config.SyncMode,
RetryInterval: config.RetryInterval / 60, // 秒转分钟
MaxRetries: config.RetryCount, // ✅ 修复:使用 RetryCount
Enabled: config.Enabled,
}
// 现在获取写锁,执行同步
s.mu.Lock()
defer s.mu.Unlock()
// 2. 获取本机公网 IP
fetcher := NewIPFetcher()
ipv4, err4 := fetcher.GetIPv4(ctx)
ipv6, _ := fetcher.GetIPv6(ctx) // IPv6 可能没有,忽略错误
if err4 != nil && ipv6 == "" {
return fmt.Errorf("无法获取本机公网 IP(v4/v6): %v", err4)
}
// 3. 构造要同步的记录
var records []DDNSRecord
if ipv4 != "" && (cfg.TxtRecordName == "A" || cfg.TxtRecordName == "") { // cfg.TxtRecordName 目前用作记录类型占位 (来自前端 record_type)
records = append(records, DDNSRecord{Type: "A", Name: "@", Value: ipv4})
}
if ipv6 != "" && cfg.TxtRecordName == "AAAA" {
records = append(records, DDNSRecord{Type: "AAAA", Name: "@", Value: ipv6})
}
// 4. 根据厂商分发
var provider DDNSProvider
switch cfg.Provider {
case "cloudflare":
provider = NewCloudflareProvider(cfg.AccessKeySecret, cfg.Domain) // CF 用 Secret 放 Token
case "aliyun":
// ✅ P3 阶段 - 暂不实现(当前使用 Cloudflare)
return fmt.Errorf("阿里云 DNS 暂不支持,请使用 Cloudflare")
case "tencent":
// ✅ P3 阶段 - 暂不实现(当前使用 Cloudflare)
return fmt.Errorf("腾讯云 DNS 暂不支持,请使用 Cloudflare")
default:
return fmt.Errorf("不支持的 DDNS 服务商:%s", cfg.Provider)
}
if provider != nil {
if err := provider.SyncRecords(ctx, cfg.Domain, records); err != nil {
return fmt.Errorf("同步到 %s 失败: %w", cfg.Provider, err)
}
}
// 更新状态
now := time.Now()
// 注意这里直接改数据库而不是发给前端
s.db.Model(&model.DDNSConfig{}).Where("provider = ?", cfg.Provider).Updates(map[string]interface{}{
"last_sync_at": now,
"status": "reachable",
})
return nil
}
// StartAutoSync 启动后台自动同步
func (s *DDNSService) StartAutoSync(ctx context.Context) {
// 初始延迟启动,避免服务刚起就发请求
time.Sleep(10 * time.Second)
for {
cfg, err := s.GetConfig(ctx)
if err == nil && cfg.Enabled && cfg.Provider != "" && cfg.SyncMode == "auto" {
// 执行同步
syncErr := s.SyncNow(ctx)
if syncErr != nil {
// ✅ 记录错误日志(P3 阶段 - 简单打印)
fmt.Printf("❌ DDNS 自动同步失败:provider=%s, error=%v\n", cfg.Provider, syncErr)
}
}
// 等待指定的重试间隔
interval := 5 * time.Minute // 默认 5 分钟
if cfg != nil && cfg.RetryInterval > 0 {
interval = time.Duration(cfg.RetryInterval) * time.Minute
}
select {
case <-ctx.Done():
return
case <-time.After(interval):
}
}
}