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