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