diff --git a/go.mod b/go.mod index 3b42fd0..55ccd4a 100644 --- a/go.mod +++ b/go.mod @@ -19,7 +19,13 @@ require ( github.com/jpillora/backoff v1.0.0 github.com/kardianos/service v1.2.4 github.com/labstack/echo/v4 v4.13.4 + github.com/libdns/alidns v1.0.6-beta.1 + github.com/libdns/cloudflare v0.2.2 + github.com/libdns/huaweicloud v1.0.0 + github.com/libdns/libdns v1.1.1 + github.com/libdns/tencentcloud v1.4.3 github.com/minio/selfupdate v0.6.0 + github.com/oschwald/geoip2-golang v1.13.0 github.com/prometheus-community/pro-bing v0.7.0 github.com/shirou/gopsutil/v4 v4.25.10 github.com/spf13/afero v1.15.0 @@ -58,7 +64,6 @@ require ( github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect - github.com/oschwald/geoip2-golang v1.13.0 // indirect github.com/oschwald/maxminddb-golang v1.13.0 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect diff --git a/go.sum b/go.sum index 79d9da9..6d6e5bf 100644 --- a/go.sum +++ b/go.sum @@ -93,6 +93,19 @@ github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0 github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/libdns/alidns v1.0.4 h1:Rc3Yy2SzMoho+3q3+fNy9vOVr2h9dcL8OLTgNKgxYbU= +github.com/libdns/alidns v1.0.4/go.mod h1:e18uAG6GanfRhcJj6/tps2rCMzQJaYVcGKT+ELjdjGE= +github.com/libdns/alidns v1.0.6-beta.1 h1:L5Qxh5xGzVwNndEIw744pPXp4rW0B3t5UapzmpEh15A= +github.com/libdns/alidns v1.0.6-beta.1/go.mod h1:RECwyQ88e9VqQVtSrvX76o1ux3gQUKGzMgxICi+u7Ec= +github.com/libdns/cloudflare v0.2.2 h1:XWHv+C1dDcApqazlh08Q6pjytYLgR2a+Y3xrXFu0vsI= +github.com/libdns/cloudflare v0.2.2/go.mod h1:w9uTmRCDlAoafAsTPnn2nJ0XHK/eaUMh86DUk8BWi60= +github.com/libdns/huaweicloud v1.0.0 h1:BQUIkOAjF++ouiANRIE3jdMlCfeiAAr6tGqb+8hM1jo= +github.com/libdns/huaweicloud v1.0.0/go.mod h1:W+XywkW+C93fM50Ayklf6KuoCHZTqbrmFUEtpQnVvcU= +github.com/libdns/libdns v0.2.0/go.mod h1:yQCXzk1lEZmmCPa857bnk4TsOiqYasqpyOEeSObbb40= +github.com/libdns/libdns v1.1.1 h1:wPrHrXILoSHKWJKGd0EiAVmiJbFShguILTg9leS/P/U= +github.com/libdns/libdns v1.1.1/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ= +github.com/libdns/tencentcloud v1.4.3 h1:xJHYLL1TdPeOtUr6Bu6dHTd1TU6/VFm7BFc2EAzAlvc= +github.com/libdns/tencentcloud v1.4.3/go.mod h1:Be9gY3tDa12DuAPU79RV9NZIcjY6qg5s7zKPsP26yAM= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= diff --git a/internal/app.go b/internal/app.go index 3c64390..0f03dc8 100644 --- a/internal/app.go +++ b/internal/app.go @@ -11,6 +11,7 @@ import ( "github.com/dushixiang/pika/internal/config" "github.com/dushixiang/pika/internal/handler" + "github.com/dushixiang/pika/internal/migrations" "github.com/dushixiang/pika/internal/models" "github.com/dushixiang/pika/internal/scheduler" "github.com/dushixiang/pika/pkg/replace" @@ -41,6 +42,12 @@ func setup(app *orz.App) error { return err } + // 执行 DDNS domains 字段拆分迁移 + if err := migrations.MigrateDDNSDomainsSplit(app.GetDatabase(), app.Logger()); err != nil { + app.Logger().Error("DDNS domains 迁移失败", zap.Error(err)) + // 不返回错误,继续启动(迁移失败不应阻止应用启动) + } + // 读取应用配置 var appConfig config.AppConfig _config := app.GetConfig() @@ -97,6 +104,9 @@ func setup(app *orz.App) error { // 启动监控统计计算任务 go startMonitorStatsCalculation(ctx, components, app.Logger()) + // 启动 DDNS 定时任务 + go components.DDNSService.Run(ctx) + // 设置API setupApi(app, components) @@ -262,6 +272,21 @@ func setupApi(app *orz.App, components *AppComponents) { adminApi.GET("/monitors/:id", components.MonitorHandler.Get) adminApi.PUT("/monitors/:id", components.MonitorHandler.Update) adminApi.DELETE("/monitors/:id", components.MonitorHandler.Delete) + + // DNS Provider 管理 + adminApi.GET("/dns-providers", components.DNSProviderHandler.GetAll) + adminApi.POST("/dns-providers", components.DNSProviderHandler.Upsert) + adminApi.DELETE("/dns-providers/:provider", components.DNSProviderHandler.Delete) + + // DDNS 配置管理 + adminApi.GET("/ddns", components.DDNSHandler.Paging) + adminApi.POST("/ddns", components.DDNSHandler.Create) + adminApi.GET("/ddns/:id", components.DDNSHandler.Get) + adminApi.PUT("/ddns/:id", components.DDNSHandler.Update) + adminApi.DELETE("/ddns/:id", components.DDNSHandler.Delete) + adminApi.POST("/ddns/:id/enable", components.DDNSHandler.Enable) + adminApi.POST("/ddns/:id/disable", components.DDNSHandler.Disable) + adminApi.GET("/ddns/:id/records", components.DDNSHandler.GetRecords) } // OIDC 认证路由(如果启用) @@ -295,6 +320,8 @@ func autoMigrate(database *gorm.DB) error { &models.TamperProtectConfig{}, &models.TamperEvent{}, &models.TamperAlert{}, + &models.DDNSConfig{}, + &models.DDNSRecord{}, // 聚合表 &models.AggregatedCPUMetricModel{}, &models.AggregatedMemoryMetricModel{}, diff --git a/internal/ddns/factory.go b/internal/ddns/factory.go new file mode 100644 index 0000000..11295b5 --- /dev/null +++ b/internal/ddns/factory.go @@ -0,0 +1,84 @@ +package ddns + +import ( + "fmt" + + "github.com/libdns/alidns" + "github.com/libdns/cloudflare" + "github.com/libdns/huaweicloud" + "github.com/libdns/tencentcloud" +) + +// NewProvider 创建 DNS 提供商 +func NewProvider(providerType string, config map[string]string) (Provider, error) { + var libdnsProvider interface{} + + switch providerType { + case "aliyun": + accessKeyID, ok := config["accessKeyId"] + if !ok || accessKeyID == "" { + return nil, fmt.Errorf("阿里云 AccessKeyId 不能为空") + } + accessKeySecret, ok := config["accessKeySecret"] + if !ok || accessKeySecret == "" { + return nil, fmt.Errorf("阿里云 AccessKeySecret 不能为空") + } + + libdnsProvider = &alidns.Provider{ + CredentialInfo: alidns.CredentialInfo{ + AccessKeyID: accessKeyID, + AccessKeySecret: accessKeySecret, + }, + } + + case "tencentcloud": + secretID, ok := config["secretId"] + if !ok || secretID == "" { + return nil, fmt.Errorf("腾讯云 SecretId 不能为空") + } + secretKey, ok := config["secretKey"] + if !ok || secretKey == "" { + return nil, fmt.Errorf("腾讯云 SecretKey 不能为空") + } + + libdnsProvider = &tencentcloud.Provider{ + SecretId: secretID, + SecretKey: secretKey, + } + + case "cloudflare": + apiToken, ok := config["apiToken"] + if !ok || apiToken == "" { + return nil, fmt.Errorf("Cloudflare API Token 不能为空") + } + + libdnsProvider = &cloudflare.Provider{ + APIToken: apiToken, + } + + case "huaweicloud": + accessKeyID, ok := config["accessKeyId"] + if !ok || accessKeyID == "" { + return nil, fmt.Errorf("华为云 AccessKeyId 不能为空") + } + secretAccessKey, ok := config["secretAccessKey"] + if !ok || secretAccessKey == "" { + return nil, fmt.Errorf("华为云 SecretAccessKey 不能为空") + } + region, ok := config["region"] + if !ok || region == "" { + region = "cn-south-1" // 默认区域 + } + + libdnsProvider = &huaweicloud.Provider{ + AccessKeyId: accessKeyID, + SecretAccessKey: secretAccessKey, + RegionId: region, + } + + default: + return nil, fmt.Errorf("不支持的 DNS 服务商: %s", providerType) + } + + return NewLibDNSProvider(libdnsProvider) +} diff --git a/internal/ddns/libdns_provider.go b/internal/ddns/libdns_provider.go new file mode 100644 index 0000000..9b044e7 --- /dev/null +++ b/internal/ddns/libdns_provider.go @@ -0,0 +1,123 @@ +package ddns + +import ( + "context" + "fmt" + "net/netip" + "time" + + "github.com/libdns/libdns" +) + +// LibDNSProvider 基于 libdns 的通用 DNS 提供商 +type LibDNSProvider struct { + getter libdns.RecordGetter + setter libdns.RecordSetter +} + +// NewLibDNSProvider 创建基于 libdns 的提供商 +func NewLibDNSProvider(provider interface{}) (*LibDNSProvider, error) { + getter, okGetter := provider.(libdns.RecordGetter) + setter, okSetter := provider.(libdns.RecordSetter) + + if !okGetter || !okSetter { + return nil, fmt.Errorf("提供商必须实现 RecordGetter 和 RecordSetter 接口") + } + + return &LibDNSProvider{ + getter: getter, + setter: setter, + }, nil +} + +// UpdateRecord 更新 DNS 记录 +func (p *LibDNSProvider) UpdateRecord(ctx context.Context, domain, recordType, ip string) error { + zone, name, err := parseDomain(domain) + if err != nil { + return err + } + + // 解析 IP 地址 + addr, err := netip.ParseAddr(ip) + if err != nil { + return fmt.Errorf("无效的 IP 地址: %w", err) + } + + // 获取现有记录 + records, err := p.getter.GetRecords(ctx, zone) + if err != nil { + return fmt.Errorf("获取 DNS 记录失败: %w", err) + } + + // 查找匹配的记录 + existingRecord := findAddressRecord(records, name, recordType) + + // 如果记录存在且 IP 相同,无需更新 + if existingRecord != nil { + existingRR := existingRecord.RR() + existingAddr, ok := existingRecord.(libdns.Address) + if ok && existingAddr.IP == addr { + return nil + } + // 检查是否是 RR 类型且 Data 相同 + if existingRR.Data == ip { + return nil + } + } + + // 构建新记录 + newRecord := libdns.Address{ + Name: name, + IP: addr, + TTL: 10 * time.Minute, // 默认 TTL 10 分钟 + } + + // 更新记录 + _, err = p.setter.SetRecords(ctx, zone, []libdns.Record{newRecord}) + if err != nil { + return fmt.Errorf("更新 DNS 记录失败: %w", err) + } + + return nil +} + +// GetRecord 获取 DNS 记录 +func (p *LibDNSProvider) GetRecord(ctx context.Context, domain, recordType string) (string, error) { + zone, name, err := parseDomain(domain) + if err != nil { + return "", err + } + + // 获取所有记录 + records, err := p.getter.GetRecords(ctx, zone) + if err != nil { + return "", fmt.Errorf("获取 DNS 记录失败: %w", err) + } + + // 查找匹配的记录 + record := findAddressRecord(records, name, recordType) + if record == nil { + return "", fmt.Errorf("未找到 DNS 记录") + } + + // 尝试从 Address 类型获取 IP + if addr, ok := record.(libdns.Address); ok { + return addr.IP.String(), nil + } + + // 降级使用 RR.Data + rr := record.RR() + return rr.Data, nil +} + +// findAddressRecord 在记录列表中查找匹配的地址记录 +func findAddressRecord(records []libdns.Record, name, recordType string) libdns.Record { + for _, record := range records { + rr := record.RR() + // 匹配名称和类型 + if rr.Name == name && rr.Type == recordType { + return record + } + } + return nil +} diff --git a/internal/ddns/provider.go b/internal/ddns/provider.go new file mode 100644 index 0000000..ee9fedc --- /dev/null +++ b/internal/ddns/provider.go @@ -0,0 +1,49 @@ +package ddns + +import ( + "context" + "fmt" + "strings" +) + +// Provider DNS 服务商接口 +type Provider interface { + // UpdateRecord 更新 DNS 记录 + // domain: 完整域名,如 ddns.example.com + // recordType: 记录类型,A 或 AAAA + // ip: IP 地址 + UpdateRecord(ctx context.Context, domain, recordType, ip string) error + + // GetRecord 获取 DNS 记录 + // domain: 完整域名 + // recordType: 记录类型,A 或 AAAA + // 返回: 当前记录的 IP 地址 + GetRecord(ctx context.Context, domain, recordType string) (string, error) +} + +// RecordType DNS 记录类型 +const ( + RecordTypeA = "A" + RecordTypeAAAA = "AAAA" +) + +// parseDomain 解析域名,提取主域名和子域名 +// 例如: ddns.example.com -> example.com, ddns +// 例如: example.com -> example.com, @ +func parseDomain(fullDomain string) (zone, name string, err error) { + parts := strings.Split(fullDomain, ".") + if len(parts) < 2 { + return "", "", fmt.Errorf("无效的域名格式: %s", fullDomain) + } + + if len(parts) == 2 { + // 例如: example.com,主机记录是 @ + return fullDomain, "@", nil + } + + // 例如: ddns.example.com -> zone: example.com, name: ddns + zone = strings.Join(parts[len(parts)-2:], ".") + name = strings.Join(parts[:len(parts)-2], ".") + + return zone, name, nil +} diff --git a/internal/handler/agent_handler.go b/internal/handler/agent_handler.go index 8cc17e4..747b281 100644 --- a/internal/handler/agent_handler.go +++ b/internal/handler/agent_handler.go @@ -29,12 +29,13 @@ type AgentHandler struct { metricService *service.MetricService monitorSvc *service.MonitorService tamperService *service.TamperService + ddnsService *service.DDNSService wsManager *ws.Manager upgrader websocket.Upgrader } func NewAgentHandler(logger *zap.Logger, agentService *service.AgentService, metricService *service.MetricService, - monitorService *service.MonitorService, tamperService *service.TamperService, wsManager *ws.Manager) *AgentHandler { + monitorService *service.MonitorService, tamperService *service.TamperService, ddnsService *service.DDNSService, wsManager *ws.Manager) *AgentHandler { h := &AgentHandler{ logger: logger, @@ -42,6 +43,7 @@ func NewAgentHandler(logger *zap.Logger, agentService *service.AgentService, met metricService: metricService, monitorSvc: monitorService, tamperService: tamperService, + ddnsService: ddnsService, wsManager: wsManager, } @@ -186,6 +188,15 @@ func (h *AgentHandler) handleWebSocketMessage(ctx context.Context, agentID strin } return h.tamperService.CreateAlert(agentID, alertData.Path, alertData.Details, alertData.Restored, alertData.Timestamp) + case protocol.MessageTypeDDNSIPReport: + // DDNS IP 上报 - 异步处理,避免阻塞 WebSocket 消息循环 + var ipReport protocol.DDNSIPReportData + if err := json.Unmarshal(data, &ipReport); err != nil { + h.logger.Error("failed to unmarshal ddns ip report", zap.Error(err)) + return err + } + return h.ddnsService.HandleIPReport(ctx, agentID, &ipReport) + case protocol.MessageTypeTamperProtect: // 防篡改配置响应 var protectResp protocol.TamperProtectResponse diff --git a/internal/handler/ddns_handler.go b/internal/handler/ddns_handler.go new file mode 100644 index 0000000..6e9e1c3 --- /dev/null +++ b/internal/handler/ddns_handler.go @@ -0,0 +1,295 @@ +package handler + +import ( + "time" + + "github.com/dushixiang/pika/internal/models" + "github.com/dushixiang/pika/internal/service" + "github.com/go-orz/orz" + "github.com/google/uuid" + "github.com/labstack/echo/v4" + "go.uber.org/zap" +) + +type DDNSHandler struct { + logger *zap.Logger + ddnsService *service.DDNSService +} + +func NewDDNSHandler(logger *zap.Logger, ddnsService *service.DDNSService) *DDNSHandler { + return &DDNSHandler{ + logger: logger, + ddnsService: ddnsService, + } +} + +// CreateConfigRequest 创建 DDNS 配置请求 +type CreateConfigRequest struct { + AgentID string `json:"agentId" validate:"required"` + Name string `json:"name" validate:"required"` + Provider string `json:"provider" validate:"required"` + DomainsIPv4 []string `json:"domainsIpv4"` + DomainsIPv6 []string `json:"domainsIpv6"` + EnableIPv4 bool `json:"enableIpv4"` + EnableIPv6 bool `json:"enableIpv6"` + IPv4GetMethod string `json:"ipv4GetMethod"` + IPv6GetMethod string `json:"ipv6GetMethod"` + IPv4GetValue string `json:"ipv4GetValue"` + IPv6GetValue string `json:"ipv6GetValue"` +} + +// UpdateConfigRequest 更新 DDNS 配置请求 +type UpdateConfigRequest struct { + Name string `json:"name"` + Provider string `json:"provider"` + DomainsIPv4 []string `json:"domainsIpv4"` + DomainsIPv6 []string `json:"domainsIpv6"` + EnableIPv4 bool `json:"enableIpv4"` + EnableIPv6 bool `json:"enableIpv6"` + IPv4GetMethod string `json:"ipv4GetMethod"` + IPv6GetMethod string `json:"ipv6GetMethod"` + IPv4GetValue string `json:"ipv4GetValue"` + IPv6GetValue string `json:"ipv6GetValue"` +} + +// Paging DDNS 配置分页查询 +func (h *DDNSHandler) Paging(c echo.Context) error { + agentID := c.QueryParam("agentId") + name := c.QueryParam("name") + + pr := orz.GetPageRequest(c, "created_at", "name") + + builder := orz.NewPageBuilder(h.ddnsService.ConfigRepo). + PageRequest(pr). + Contains("name", name) + + if agentID != "" { + builder = builder.Equal("agent_id", agentID) + } + + ctx := c.Request().Context() + page, err := builder.Execute(ctx) + if err != nil { + return err + } + + return orz.Ok(c, orz.Map{ + "items": page.Items, + "total": page.Total, + }) +} + +// Create 创建 DDNS 配置 +func (h *DDNSHandler) Create(c echo.Context) error { + var req CreateConfigRequest + if err := c.Bind(&req); err != nil { + return err + } + if err := c.Validate(&req); err != nil { + return err + } + + // 支持的服务商:aliyun, tencentcloud, cloudflare, huaweicloud + validProviders := map[string]bool{ + "aliyun": true, + "tencentcloud": true, + "cloudflare": true, + "huaweicloud": true, + } + if !validProviders[req.Provider] { + return orz.NewError(400, "不支持的 DNS 服务商") + } + + // 验证 IP 获取配置 + if req.EnableIPv4 && req.IPv4GetMethod == "" { + return orz.NewError(400, "IPv4 获取方式不能为空") + } + if req.EnableIPv6 && req.IPv6GetMethod == "" { + return orz.NewError(400, "IPv6 获取方式不能为空") + } + // 验证 IP 获取方式只能是 api 或 interface + validMethods := map[string]bool{"api": true, "interface": true} + if req.EnableIPv4 && !validMethods[req.IPv4GetMethod] { + return orz.NewError(400, "IPv4 获取方式只能是 api 或 interface") + } + if req.EnableIPv6 && !validMethods[req.IPv6GetMethod] { + return orz.NewError(400, "IPv6 获取方式只能是 api 或 interface") + } + + config := &models.DDNSConfig{ + ID: uuid.New().String(), + AgentID: req.AgentID, + Name: req.Name, + Enabled: true, // 默认启用 + Provider: req.Provider, + DomainsIPv4: req.DomainsIPv4, + DomainsIPv6: req.DomainsIPv6, + EnableIPv4: req.EnableIPv4, + EnableIPv6: req.EnableIPv6, + IPv4GetMethod: req.IPv4GetMethod, + IPv6GetMethod: req.IPv6GetMethod, + IPv4GetValue: req.IPv4GetValue, + IPv6GetValue: req.IPv6GetValue, + CreatedAt: time.Now().UnixMilli(), + UpdatedAt: time.Now().UnixMilli(), + } + + ctx := c.Request().Context() + if err := h.ddnsService.CreateConfig(ctx, config); err != nil { + h.logger.Error("failed to create ddns config", zap.Error(err)) + return err + } + + return orz.Ok(c, config) +} + +// Get 获取 DDNS 配置详情 +func (h *DDNSHandler) Get(c echo.Context) error { + id := c.Param("id") + ctx := c.Request().Context() + + config, err := h.ddnsService.GetConfig(ctx, id) + if err != nil { + h.logger.Error("failed to get ddns config", zap.Error(err)) + return err + } + + return orz.Ok(c, config) +} + +// Update 更新 DDNS 配置 +func (h *DDNSHandler) Update(c echo.Context) error { + id := c.Param("id") + + var req UpdateConfigRequest + if err := c.Bind(&req); err != nil { + return err + } + if err := c.Validate(&req); err != nil { + return err + } + + ctx := c.Request().Context() + + // 检查配置是否存在 + existing, err := h.ddnsService.GetConfig(ctx, id) + if err != nil { + h.logger.Error("failed to get ddns config", zap.Error(err)) + return err + } + + // 更新字段 + if req.Name != "" { + existing.Name = req.Name + } + if req.Provider != "" { + existing.Provider = req.Provider + } + if req.DomainsIPv4 != nil { + existing.DomainsIPv4 = req.DomainsIPv4 + } + if req.DomainsIPv6 != nil { + existing.DomainsIPv6 = req.DomainsIPv6 + } + existing.EnableIPv4 = req.EnableIPv4 + existing.EnableIPv6 = req.EnableIPv6 + if req.IPv4GetMethod != "" { + existing.IPv4GetMethod = req.IPv4GetMethod + } + if req.IPv6GetMethod != "" { + existing.IPv6GetMethod = req.IPv6GetMethod + } + existing.IPv4GetValue = req.IPv4GetValue + existing.IPv6GetValue = req.IPv6GetValue + existing.UpdatedAt = time.Now().UnixMilli() + + if err := h.ddnsService.UpdateConfig(ctx, existing); err != nil { + h.logger.Error("failed to update ddns config", zap.Error(err)) + return err + } + + return orz.Ok(c, orz.Map{ + "message": "DDNS 配置更新成功", + }) +} + +// Delete 删除 DDNS 配置 +func (h *DDNSHandler) Delete(c echo.Context) error { + id := c.Param("id") + ctx := c.Request().Context() + + if err := h.ddnsService.DeleteConfig(ctx, id); err != nil { + h.logger.Error("failed to delete ddns config", zap.Error(err)) + return err + } + + return orz.Ok(c, orz.Map{ + "message": "DDNS 配置删除成功", + }) +} + +// Enable 启用 DDNS 配置 +func (h *DDNSHandler) Enable(c echo.Context) error { + id := c.Param("id") + ctx := c.Request().Context() + + config, err := h.ddnsService.GetConfig(ctx, id) + if err != nil { + h.logger.Error("failed to get ddns config", zap.Error(err)) + return err + } + + config.Enabled = true + config.UpdatedAt = time.Now().UnixMilli() + + if err := h.ddnsService.UpdateConfig(ctx, config); err != nil { + h.logger.Error("failed to enable ddns config", zap.Error(err)) + return err + } + + return orz.Ok(c, orz.Map{ + "message": "DDNS 配置启用成功", + }) +} + +// Disable 禁用 DDNS 配置 +func (h *DDNSHandler) Disable(c echo.Context) error { + id := c.Param("id") + ctx := c.Request().Context() + + config, err := h.ddnsService.GetConfig(ctx, id) + if err != nil { + h.logger.Error("failed to get ddns config", zap.Error(err)) + return err + } + + config.Enabled = false + config.UpdatedAt = time.Now().UnixMilli() + + if err := h.ddnsService.UpdateConfig(ctx, config); err != nil { + h.logger.Error("failed to disable ddns config", zap.Error(err)) + return err + } + + return orz.Ok(c, orz.Map{ + "message": "DDNS 配置禁用成功", + }) +} + +// GetRecords 获取 DDNS 更新记录 +func (h *DDNSHandler) GetRecords(c echo.Context) error { + id := c.Param("id") + ctx := c.Request().Context() + + limit := 100 // 默认返回最近 100 条 + records, err := h.ddnsService.ListRecords(ctx, id, limit) + if err != nil { + h.logger.Error("failed to get ddns records", zap.Error(err)) + return err + } + + return orz.Ok(c, orz.Map{ + "items": records, + "total": len(records), + }) +} diff --git a/internal/handler/dns_provider_handler.go b/internal/handler/dns_provider_handler.go new file mode 100644 index 0000000..4f0b2b6 --- /dev/null +++ b/internal/handler/dns_provider_handler.go @@ -0,0 +1,183 @@ +package handler + +import ( + "net/http" + "strings" + + "github.com/dushixiang/pika/internal/models" + "github.com/dushixiang/pika/internal/service" + "github.com/labstack/echo/v4" + "go.uber.org/zap" +) + +type DNSProviderHandler struct { + logger *zap.Logger + propertyService *service.PropertyService +} + +func NewDNSProviderHandler(logger *zap.Logger, propertyService *service.PropertyService) *DNSProviderHandler { + return &DNSProviderHandler{ + logger: logger, + propertyService: propertyService, + } +} + +// DNSProviderRequest 创建/更新 DNS Provider 请求 +type DNSProviderRequest struct { + Provider string `json:"provider"` // 服务商类型 + Enabled bool `json:"enabled"` // 是否启用 + Config map[string]interface{} `json:"config"` // 配置对象 +} + +// DNSProviderResponse DNS Provider 响应(脱敏) +type DNSProviderResponse struct { + Provider string `json:"provider"` // 服务商类型 + Enabled bool `json:"enabled"` // 是否启用 + Config map[string]interface{} `json:"config"` // 配置对象(已脱敏) +} + +// maskSensitiveData 脱敏敏感信息 +func maskSensitiveData(config map[string]interface{}) map[string]interface{} { + if config == nil { + return nil + } + + masked := make(map[string]interface{}) + for key, value := range config { + lowerKey := strings.ToLower(key) + // 对所有包含敏感关键词的字段进行脱敏 + if strings.Contains(lowerKey, "secret") || + strings.Contains(lowerKey, "key") || + strings.Contains(lowerKey, "token") || + strings.Contains(lowerKey, "password") { + if str, ok := value.(string); ok && str != "" { + // 保留前后各2个字符,中间用 **** 替代 + if len(str) <= 4 { + masked[key] = "****" + } else { + masked[key] = str[:2] + "****" + str[len(str)-2:] + } + } else { + masked[key] = "****" + } + } else { + masked[key] = value + } + } + return masked +} + +// GetAll 获取所有 DNS Provider 配置(脱敏) +func (h *DNSProviderHandler) GetAll(c echo.Context) error { + ctx := c.Request().Context() + + providers, err := h.propertyService.GetDNSProviderConfigs(ctx) + if err != nil { + h.logger.Error("获取 DNS Provider 配置失败", zap.Error(err)) + return echo.NewHTTPError(http.StatusInternalServerError, "获取配置失败") + } + + // 脱敏处理 + var response []DNSProviderResponse + for _, p := range providers { + response = append(response, DNSProviderResponse{ + Provider: p.Provider, + Enabled: p.Enabled, + Config: maskSensitiveData(p.Config), + }) + } + + return c.JSON(http.StatusOK, response) +} + +// Upsert 创建或更新 DNS Provider 配置 +func (h *DNSProviderHandler) Upsert(c echo.Context) error { + ctx := c.Request().Context() + + var req DNSProviderRequest + if err := c.Bind(&req); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "请求参数错误") + } + + // 验证 provider 类型 + validProviders := map[string]bool{ + "aliyun": true, + "tencentcloud": true, + "cloudflare": true, + "huaweicloud": true, + } + if !validProviders[req.Provider] { + return echo.NewHTTPError(http.StatusBadRequest, "不支持的 DNS 服务商类型") + } + + // 验证配置字段 + if err := h.validateProviderConfig(req.Provider, req.Config); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, err.Error()) + } + + provider := models.DNSProviderConfig{ + Provider: req.Provider, + Enabled: req.Enabled, + Config: req.Config, + } + + if err := h.propertyService.UpsertDNSProvider(ctx, provider); err != nil { + h.logger.Error("保存 DNS Provider 配置失败", zap.Error(err)) + return echo.NewHTTPError(http.StatusInternalServerError, "保存配置失败") + } + + return c.JSON(http.StatusOK, map[string]string{"message": "保存成功"}) +} + +// Delete 删除 DNS Provider 配置 +func (h *DNSProviderHandler) Delete(c echo.Context) error { + ctx := c.Request().Context() + provider := c.Param("provider") + + if provider == "" { + return echo.NewHTTPError(http.StatusBadRequest, "provider 参数不能为空") + } + + if err := h.propertyService.DeleteDNSProvider(ctx, provider); err != nil { + h.logger.Error("删除 DNS Provider 配置失败", zap.Error(err)) + return echo.NewHTTPError(http.StatusInternalServerError, "删除配置失败") + } + + return c.JSON(http.StatusOK, map[string]string{"message": "删除成功"}) +} + +// validateProviderConfig 验证不同服务商的配置字段 +func (h *DNSProviderHandler) validateProviderConfig(provider string, config map[string]interface{}) error { + switch provider { + case "aliyun": + if config["accessKeyId"] == nil || config["accessKeyId"] == "" { + return echo.NewHTTPError(http.StatusBadRequest, "accessKeyId 不能为空") + } + if config["accessKeySecret"] == nil || config["accessKeySecret"] == "" { + return echo.NewHTTPError(http.StatusBadRequest, "accessKeySecret 不能为空") + } + case "tencentcloud": + if config["secretId"] == nil || config["secretId"] == "" { + return echo.NewHTTPError(http.StatusBadRequest, "secretId 不能为空") + } + if config["secretKey"] == nil || config["secretKey"] == "" { + return echo.NewHTTPError(http.StatusBadRequest, "secretKey 不能为空") + } + case "cloudflare": + if config["apiToken"] == nil || config["apiToken"] == "" { + return echo.NewHTTPError(http.StatusBadRequest, "apiToken 不能为空") + } + case "huaweicloud": + if config["accessKeyId"] == nil || config["accessKeyId"] == "" { + return echo.NewHTTPError(http.StatusBadRequest, "accessKeyId 不能为空") + } + if config["secretAccessKey"] == nil || config["secretAccessKey"] == "" { + return echo.NewHTTPError(http.StatusBadRequest, "secretAccessKey 不能为空") + } + // region 可选,提供默认值 + if config["region"] == nil || config["region"] == "" { + config["region"] = "cn-south-1" + } + } + return nil +} diff --git a/internal/models/ddns.go b/internal/models/ddns.go new file mode 100644 index 0000000..88050cc --- /dev/null +++ b/internal/models/ddns.go @@ -0,0 +1,49 @@ +package models + +import "gorm.io/datatypes" + +// DDNSConfig DDNS 配置 +type DDNSConfig struct { + ID string `gorm:"primaryKey" json:"id"` // 配置ID (UUID) + AgentID string `gorm:"index" json:"agentId"` // 探针ID + Name string `json:"name"` // 配置名称 + Enabled bool `gorm:"default:true" json:"enabled"` // 是否启用 + Provider string `gorm:"index" json:"provider"` // DNS服务商类型: aliyun, tencentcloud, cloudflare, huaweicloud + + // 域名配置(IPv4 和 IPv6 分开) + DomainsIPv4 datatypes.JSONSlice[string] `json:"domainsIpv4"` // IPv4 域名列表 + DomainsIPv6 datatypes.JSONSlice[string] `json:"domainsIpv6"` // IPv6 域名列表 + + // IP 获取配置 + EnableIPv4 bool `gorm:"default:true" json:"enableIpv4"` // 是否启用 IPv4 + EnableIPv6 bool `gorm:"default:false" json:"enableIpv6"` // 是否启用 IPv6 + IPv4GetMethod string `json:"ipv4GetMethod"` // IPv4 获取方式: api, interface + IPv6GetMethod string `json:"ipv6GetMethod"` // IPv6 获取方式: api, interface + IPv4GetValue string `json:"ipv4GetValue,omitempty"` // IPv4 获取配置值(接口名/API URL) + IPv6GetValue string `json:"ipv6GetValue,omitempty"` // IPv6 获取配置值(接口名/API URL) + + CreatedAt int64 `json:"createdAt"` // 创建时间(时间戳毫秒) + UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime:milli"` // 更新时间(时间戳毫秒) +} + +func (DDNSConfig) TableName() string { + return "ddns_configs" +} + +// DDNSRecord DDNS 更新记录 +type DDNSRecord struct { + ID string `gorm:"primaryKey" json:"id"` // 记录ID + ConfigID string `gorm:"index" json:"configId"` // 配置ID + AgentID string `gorm:"index" json:"agentId"` // 探针ID + Domain string `gorm:"index" json:"domain"` // 域名 + RecordType string `json:"recordType"` // 记录类型: A, AAAA + OldIP string `json:"oldIp,omitempty"` // 旧IP + NewIP string `json:"newIp"` // 新IP + Status string `json:"status"` // 更新状态: success, failed + ErrorMessage string `json:"errorMessage,omitempty"` // 错误信息 + CreatedAt int64 `gorm:"index" json:"createdAt"` // 创建时间(时间戳毫秒) +} + +func (DDNSRecord) TableName() string { + return "ddns_records" +} diff --git a/internal/models/property.go b/internal/models/property.go index 5667493..1c628ae 100644 --- a/internal/models/property.go +++ b/internal/models/property.go @@ -32,6 +32,19 @@ type NotificationChannelConfig struct { // "customBody": "" // 当 bodyTemplate 为 custom 时使用,支持变量替换 // } +// DNSProviderConfig DNS 服务商配置(存储在 Property 中) +type DNSProviderConfig struct { + Provider string `json:"provider"` // 服务商类型: aliyun, tencentcloud, cloudflare, huaweicloud + Enabled bool `json:"enabled"` // 是否启用 + Config map[string]interface{} `json:"config"` // 配置对象(敏感信息) +} + +// DNS Provider 配置格式说明: +// aliyun: { "accessKeyId": "xxx", "accessKeySecret": "xxx" } +// tencentcloud: { "secretId": "xxx", "secretKey": "xxx" } +// cloudflare: { "apiToken": "xxx" } +// huaweicloud: { "accessKeyId": "xxx", "secretAccessKey": "xxx", "region": "cn-south-1" } + // WebhookConfig 自定义 Webhook 配置结构 type WebhookConfig struct { URL string `json:"url"` // Webhook URL diff --git a/internal/protocol/ddns.go b/internal/protocol/ddns.go new file mode 100644 index 0000000..b47075b --- /dev/null +++ b/internal/protocol/ddns.go @@ -0,0 +1,20 @@ +package protocol + +// DDNSConfigData DDNS 配置数据(服务端下发给客户端) +type DDNSConfigData struct { + Enabled bool `json:"enabled"` // 是否启用 DDNS + + // IP 获取配置 + EnableIPv4 bool `json:"enableIpv4"` // 是否启用 IPv4 + EnableIPv6 bool `json:"enableIpv6"` // 是否启用 IPv6 + IPv4GetMethod string `json:"ipv4GetMethod,omitempty"` // IPv4 获取方式: api, interface, command + IPv6GetMethod string `json:"ipv6GetMethod,omitempty"` // IPv6 获取方式: api, interface, command + IPv4GetValue string `json:"ipv4GetValue,omitempty"` // IPv4 获取配置值(接口名/API URL/命令) + IPv6GetValue string `json:"ipv6GetValue,omitempty"` // IPv6 获取配置值(接口名/API URL/命令) +} + +// DDNSIPReportData DDNS IP 上报数据(客户端发送) +type DDNSIPReportData struct { + IPv4 string `json:"ipv4,omitempty"` // IPv4 地址 + IPv6 string `json:"ipv6,omitempty"` // IPv6 地址 +} diff --git a/internal/protocol/message.go b/internal/protocol/message.go index 404c307..a95114d 100644 --- a/internal/protocol/message.go +++ b/internal/protocol/message.go @@ -54,6 +54,9 @@ const ( MessageTypeTamperProtect MessageType = "tamper_protect" MessageTypeTamperEvent MessageType = "tamper_event" MessageTypeTamperAlert MessageType = "tamper_alert" + // DDNS 消息 + MessageTypeDDNSConfig MessageType = "ddns_config" + MessageTypeDDNSIPReport MessageType = "ddns_ip_report" ) type MetricType string diff --git a/internal/repo/ddns_config_repo.go b/internal/repo/ddns_config_repo.go new file mode 100644 index 0000000..21554fe --- /dev/null +++ b/internal/repo/ddns_config_repo.go @@ -0,0 +1,64 @@ +package repo + +import ( + "context" + + "github.com/dushixiang/pika/internal/models" + "github.com/go-orz/orz" + "gorm.io/gorm" +) + +type DDNSConfigRepo struct { + orz.Repository[models.DDNSConfig, string] + db *gorm.DB +} + +func NewDDNSConfigRepo(db *gorm.DB) *DDNSConfigRepo { + return &DDNSConfigRepo{ + Repository: orz.NewRepository[models.DDNSConfig, string](db), + db: db, + } +} + +// FindByAgentID 根据探针ID查找DDNS配置 +func (r *DDNSConfigRepo) FindByAgentID(ctx context.Context, agentID string) (*models.DDNSConfig, error) { + var config models.DDNSConfig + err := r.db.WithContext(ctx). + Where("agent_id = ?", agentID). + First(&config).Error + if err != nil { + return nil, err + } + return &config, nil +} + +// FindEnabledByAgentID 根据探针ID查找已启用的DDNS配置 +func (r *DDNSConfigRepo) FindEnabledByAgentID(ctx context.Context, agentID string) (*models.DDNSConfig, error) { + var config models.DDNSConfig + err := r.db.WithContext(ctx). + Where("agent_id = ? AND enabled = ?", agentID, true). + First(&config).Error + if err != nil { + return nil, err + } + return &config, nil +} + +// ListByAgentID 列出探针的所有DDNS配置 +func (r *DDNSConfigRepo) ListByAgentID(ctx context.Context, agentID string) ([]models.DDNSConfig, error) { + var configs []models.DDNSConfig + err := r.db.WithContext(ctx). + Where("agent_id = ?", agentID). + Order("created_at DESC"). + Find(&configs).Error + return configs, err +} + +// FindAllEnabled 查找所有已启用的DDNS配置 +func (r *DDNSConfigRepo) FindAllEnabled(ctx context.Context) ([]models.DDNSConfig, error) { + var configs []models.DDNSConfig + err := r.db.WithContext(ctx). + Where("enabled = ?", true). + Find(&configs).Error + return configs, err +} diff --git a/internal/repo/ddns_record_repo.go b/internal/repo/ddns_record_repo.go new file mode 100644 index 0000000..cb8fcd7 --- /dev/null +++ b/internal/repo/ddns_record_repo.go @@ -0,0 +1,65 @@ +package repo + +import ( + "context" + + "github.com/dushixiang/pika/internal/models" + "github.com/go-orz/orz" + "gorm.io/gorm" +) + +type DDNSRecordRepo struct { + orz.Repository[models.DDNSRecord, string] + db *gorm.DB +} + +func NewDDNSRecordRepo(db *gorm.DB) *DDNSRecordRepo { + return &DDNSRecordRepo{ + Repository: orz.NewRepository[models.DDNSRecord, string](db), + db: db, + } +} + +// ListByConfigID 根据配置ID列出DDNS更新记录 +func (r *DDNSRecordRepo) ListByConfigID(ctx context.Context, configID string, limit int) ([]models.DDNSRecord, error) { + var records []models.DDNSRecord + query := r.db.WithContext(ctx). + Where("config_id = ?", configID). + Order("created_at DESC") + + if limit > 0 { + query = query.Limit(limit) + } + + err := query.Find(&records).Error + return records, err +} + +// ListByAgentID 根据探针ID列出DDNS更新记录 +func (r *DDNSRecordRepo) ListByAgentID(ctx context.Context, agentID string, limit int) ([]models.DDNSRecord, error) { + var records []models.DDNSRecord + query := r.db.WithContext(ctx). + Where("agent_id = ?", agentID). + Order("created_at DESC") + + if limit > 0 { + query = query.Limit(limit) + } + + err := query.Find(&records).Error + return records, err +} + +// DeleteByConfigID 删除配置相关的所有记录 +func (r *DDNSRecordRepo) DeleteByConfigID(ctx context.Context, configID string) error { + return r.db.WithContext(ctx). + Where("config_id = ?", configID). + Delete(&models.DDNSRecord{}).Error +} + +// DeleteByAgentID 删除探针相关的所有记录 +func (r *DDNSRecordRepo) DeleteByAgentID(ctx context.Context, agentID string) error { + return r.db.WithContext(ctx). + Where("agent_id = ?", agentID). + Delete(&models.DDNSRecord{}).Error +} diff --git a/internal/service/ddns_service.go b/internal/service/ddns_service.go new file mode 100644 index 0000000..b647dd4 --- /dev/null +++ b/internal/service/ddns_service.go @@ -0,0 +1,423 @@ +package service + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/dushixiang/pika/internal/ddns" + "github.com/dushixiang/pika/internal/models" + "github.com/dushixiang/pika/internal/protocol" + "github.com/dushixiang/pika/internal/repo" + ws "github.com/dushixiang/pika/internal/websocket" + "github.com/go-orz/cache" + "github.com/google/uuid" + "go.uber.org/zap" + "gorm.io/gorm" +) + +// ipCacheData IP 缓存数据 +type ipCacheData struct { + IPv4 string + IPv6 string +} + +type DDNSService struct { + logger *zap.Logger + ConfigRepo *repo.DDNSConfigRepo // 导出用于 handler 的 PageBuilder + recordRepo *repo.DDNSRecordRepo + propertyService *PropertyService + wsManager *ws.Manager + ipCache cache.Cache[string, *ipCacheData] // 使用内存缓存存储 IP +} + +func NewDDNSService( + logger *zap.Logger, + configRepo *repo.DDNSConfigRepo, + recordRepo *repo.DDNSRecordRepo, + propertyService *PropertyService, + wsManager *ws.Manager, +) *DDNSService { + s := &DDNSService{ + logger: logger, + ConfigRepo: configRepo, + recordRepo: recordRepo, + propertyService: propertyService, + wsManager: wsManager, + ipCache: cache.New[string, *ipCacheData](24 * time.Hour), // 24小时过期,自动清理 + } + + // 初始化 IP 缓存:从 DNS 服务商查询当前记录 + go s.initIPCache() + + return s +} + +// initIPCache 初始化 IP 缓存:从 DNS 服务商查询所有启用配置的当前 IP 记录 +func (s *DDNSService) initIPCache() { + ctx := context.Background() + + // 查询所有启用的 DDNS 配置 + configs, err := s.ConfigRepo.FindAllEnabled(ctx) + if err != nil { + s.logger.Error("初始化 IP 缓存失败:查询配置出错", zap.Error(err)) + return + } + + if len(configs) == 0 { + s.logger.Info("没有启用的 DDNS 配置,跳过 IP 缓存初始化") + return + } + + s.logger.Info("开始初始化 IP 缓存", zap.Int("配置数量", len(configs))) + + for _, config := range configs { + // 创建 DNS 提供商客户端 + provider, err := s.createProvider(ctx, &config) + if err != nil { + s.logger.Error("初始化 IP 缓存失败:创建 DNS 提供商失败", + zap.String("agentId", config.AgentID), + zap.String("provider", config.Provider), + zap.Error(err)) + continue + } + + cacheData := &ipCacheData{} + + // 查询 IPv4 记录 + if config.EnableIPv4 && len(config.DomainsIPv4) > 0 { + // 使用第一个域名查询 + domain := config.DomainsIPv4[0] + ipv4, err := provider.GetRecord(ctx, domain, ddns.RecordTypeA) + if err != nil { + s.logger.Warn("查询 IPv4 记录失败", + zap.String("agentId", config.AgentID), + zap.String("domain", domain), + zap.Error(err)) + } else { + cacheData.IPv4 = ipv4 + s.logger.Info("成功查询 IPv4 记录", + zap.String("agentId", config.AgentID), + zap.String("domain", domain), + zap.String("ipv4", ipv4)) + } + } + + // 查询 IPv6 记录 + if config.EnableIPv6 && len(config.DomainsIPv6) > 0 { + // 使用第一个域名查询 + domain := config.DomainsIPv6[0] + ipv6, err := provider.GetRecord(ctx, domain, ddns.RecordTypeAAAA) + if err != nil { + s.logger.Warn("查询 IPv6 记录失败", + zap.String("agentId", config.AgentID), + zap.String("domain", domain), + zap.Error(err)) + } else { + cacheData.IPv6 = ipv6 + s.logger.Info("成功查询 IPv6 记录", + zap.String("agentId", config.AgentID), + zap.String("domain", domain), + zap.String("ipv6", ipv6)) + } + } + + // 如果查询到了任何 IP,就放入缓存 + if cacheData.IPv4 != "" || cacheData.IPv6 != "" { + s.ipCache.Set(config.AgentID, cacheData, 24*time.Hour) + } + } + + s.logger.Info("IP 缓存初始化完成") +} + +// HandleIPReport 处理客户端上报的 IP 地址 +func (s *DDNSService) HandleIPReport(ctx context.Context, agentID string, ipData *protocol.DDNSIPReportData) error { + // 获取该探针的 DDNS 配置 + config, err := s.ConfigRepo.FindEnabledByAgentID(ctx, agentID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + // 没有配置或未启用,直接返回 + return nil + } + return fmt.Errorf("获取 DDNS 配置失败: %w", err) + } + + // 获取缓存的 IP + cachedIP, _ := s.ipCache.Get(agentID) + + var oldIPv4, oldIPv6 string + if cachedIP != nil { + oldIPv4 = cachedIP.IPv4 + oldIPv6 = cachedIP.IPv6 + } + + // 检查 IP 是否变化 + ipv4Changed := config.EnableIPv4 && ipData.IPv4 != "" && oldIPv4 != ipData.IPv4 + ipv6Changed := config.EnableIPv6 && ipData.IPv6 != "" && oldIPv6 != ipData.IPv6 + + if !ipv4Changed && !ipv6Changed { + // IP 没有变化,无需更新 + s.logger.Info("IP 未变化,无需更新", + zap.String("agentId", agentID), + zap.Bool("ipv4Changed", ipv4Changed), + zap.Bool("ipv6Changed", ipv6Changed), + zap.String("oldIPv4", oldIPv4), + zap.String("newIPv4", ipData.IPv4), + zap.String("oldIPv6", oldIPv6), + zap.String("newIPv6", ipData.IPv6), + ) + return nil + } + + s.logger.Info("检测到 IP 变化", + zap.String("agentId", agentID), + zap.Bool("ipv4Changed", ipv4Changed), + zap.Bool("ipv6Changed", ipv6Changed), + zap.String("oldIPv4", oldIPv4), + zap.String("newIPv4", ipData.IPv4), + zap.String("oldIPv6", oldIPv6), + zap.String("newIPv6", ipData.IPv6), + ) + + // 创建 DNS 提供商客户端 + provider, err := s.createProvider(ctx, config) + if err != nil { + return fmt.Errorf("创建 DNS 提供商失败: %w", err) + } + + // 更新 DNS 记录 + // 处理 IPv4 域名 + if ipv4Changed { + for _, domain := range config.DomainsIPv4 { + if err := s.updateRecord(ctx, provider, config, domain, ddns.RecordTypeA, ipData.IPv4, oldIPv4); err != nil { + s.logger.Error("更新 IPv4 域名记录失败", + zap.String("agentId", agentID), + zap.String("domain", domain), + zap.Error(err)) + } + } + } + + // 处理 IPv6 域名 + if ipv6Changed { + for _, domain := range config.DomainsIPv6 { + if err := s.updateRecord(ctx, provider, config, domain, ddns.RecordTypeAAAA, ipData.IPv6, oldIPv6); err != nil { + s.logger.Error("更新 IPv6 域名记录失败", + zap.String("agentId", agentID), + zap.String("domain", domain), + zap.Error(err)) + } + } + } + + // 更新内存缓存(24小时过期) + s.ipCache.Set(agentID, &ipCacheData{ + IPv4: ipData.IPv4, + IPv6: ipData.IPv6, + }, 24*time.Hour) + + return nil +} + +// updateRecord 更新单条 DNS 记录 +func (s *DDNSService) updateRecord( + ctx context.Context, + provider ddns.Provider, + config *models.DDNSConfig, + domain, recordType, newIP, oldIP string, +) error { + err := provider.UpdateRecord(ctx, domain, recordType, newIP) + + // 记录更新结果 + record := &models.DDNSRecord{ + ID: uuid.New().String(), + ConfigID: config.ID, + AgentID: config.AgentID, + Domain: domain, + RecordType: recordType, + OldIP: oldIP, + NewIP: newIP, + CreatedAt: time.Now().UnixMilli(), + } + + if err != nil { + record.Status = "failed" + record.ErrorMessage = err.Error() + s.logger.Error("更新 DNS 记录失败", + zap.String("domain", domain), + zap.String("recordType", recordType), + zap.String("newIP", newIP), + zap.Error(err)) + } else { + record.Status = "success" + s.logger.Info("DNS 记录更新成功", + zap.String("domain", domain), + zap.String("recordType", recordType), + zap.String("oldIP", oldIP), + zap.String("newIP", newIP)) + } + + // 保存更新记录 + if saveErr := s.recordRepo.Create(ctx, record); saveErr != nil { + s.logger.Error("保存 DDNS 更新记录失败", zap.Error(saveErr)) + } + + return err +} + +// createProvider 创建 DNS 提供商 +func (s *DDNSService) createProvider(ctx context.Context, config *models.DDNSConfig) (ddns.Provider, error) { + // 从 PropertyService 获取 DNS Provider 配置 + dnsProvider, err := s.propertyService.GetDNSProviderByType(ctx, config.Provider) + if err != nil { + return nil, fmt.Errorf("获取 DNS Provider 配置失败: %w", err) + } + + if !dnsProvider.Enabled { + return nil, fmt.Errorf("DNS Provider %s 未启用", config.Provider) + } + + // 将 Config (map[string]interface{}) 转换为 map[string]string + providerConfig := make(map[string]string) + for k, v := range dnsProvider.Config { + if str, ok := v.(string); ok { + providerConfig[k] = str + } + } + + return ddns.NewProvider(config.Provider, providerConfig) +} + +// GetConfigByAgentID 获取探针的 DDNS 配置 +func (s *DDNSService) GetConfigByAgentID(ctx context.Context, agentID string) (*models.DDNSConfig, error) { + return s.ConfigRepo.FindByAgentID(ctx, agentID) +} + +// GetDDNSConfig 将数据库配置转换为协议配置(下发给客户端) +func (s *DDNSService) GetDDNSConfig(config *models.DDNSConfig) (*protocol.DDNSConfigData, error) { + return &protocol.DDNSConfigData{ + Enabled: config.Enabled, + EnableIPv4: config.EnableIPv4, + EnableIPv6: config.EnableIPv6, + IPv4GetMethod: config.IPv4GetMethod, + IPv6GetMethod: config.IPv6GetMethod, + IPv4GetValue: config.IPv4GetValue, + IPv6GetValue: config.IPv6GetValue, + }, nil +} + +// ListConfigsByAgentID 列出探针的所有 DDNS 配置 +func (s *DDNSService) ListConfigsByAgentID(ctx context.Context, agentID string) ([]models.DDNSConfig, error) { + return s.ConfigRepo.ListByAgentID(ctx, agentID) +} + +// ListRecords 列出 DDNS 更新记录 +func (s *DDNSService) ListRecords(ctx context.Context, configID string, limit int) ([]models.DDNSRecord, error) { + return s.recordRepo.ListByConfigID(ctx, configID, limit) +} + +// ListRecordsByAgentID 列出探针的 DDNS 更新记录 +func (s *DDNSService) ListRecordsByAgentID(ctx context.Context, agentID string, limit int) ([]models.DDNSRecord, error) { + return s.recordRepo.ListByAgentID(ctx, agentID, limit) +} + +// CreateConfig 创建 DDNS 配置 +func (s *DDNSService) CreateConfig(ctx context.Context, config *models.DDNSConfig) error { + return s.ConfigRepo.Create(ctx, config) +} + +// UpdateConfig 更新 DDNS 配置 +func (s *DDNSService) UpdateConfig(ctx context.Context, config *models.DDNSConfig) error { + return s.ConfigRepo.UpdateById(ctx, config) +} + +// GetConfig 获取 DDNS 配置 +func (s *DDNSService) GetConfig(ctx context.Context, id string) (*models.DDNSConfig, error) { + config, err := s.ConfigRepo.FindById(ctx, id) + if err != nil { + return nil, err + } + return &config, nil +} + +// DeleteConfig 删除 DDNS 配置 +func (s *DDNSService) DeleteConfig(ctx context.Context, id string) error { + // 先删除相关记录 + if err := s.recordRepo.DeleteByConfigID(ctx, id); err != nil { + return err + } + // 删除配置 + return s.ConfigRepo.DeleteById(ctx, id) +} + +// Run 启动 DDNS 定时任务 +func (s *DDNSService) Run(ctx context.Context) { + // DDNS 配置检查 ticker (1 分钟) + ticker := time.NewTicker(1 * time.Minute) + defer ticker.Stop() + + s.logger.Info("DDNS 定时任务已启动") + + for { + select { + case <-ctx.Done(): + s.logger.Info("DDNS 定时任务已停止") + return + case <-ticker.C: + s.checkDDNS() + } + } +} + +// checkDDNS 定时检查并下发启用的 DDNS 配置 +func (s *DDNSService) checkDDNS() { + ctx := context.Background() + + // 查询所有启用的 DDNS 配置 + configs, err := s.ConfigRepo.FindAllEnabled(ctx) + if err != nil { + s.logger.Error("查询启用的 DDNS 配置失败", zap.Error(err)) + return + } + + // 并发向每个配置对应的在线探针发送 DDNS 配置 + for _, config := range configs { + agentID := config.AgentID + go func(id string) { + if err := s.sendDDNSConfigToAgent(&config); err != nil { + s.logger.Debug("发送 DDNS 配置失败", + zap.String("agentID", id), + zap.Error(err)) + } + }(agentID) + } +} + +// sendDDNSConfigToAgent 向指定探针发送 DDNS 配置 +func (s *DDNSService) sendDDNSConfigToAgent(config *models.DDNSConfig) error { + // 获取探针的 DDNS 配置 + configData, err := s.GetDDNSConfig(config) + if err != nil { + return err + } + + data, err := json.Marshal(configData) + if err != nil { + return err + } + + msg := protocol.Message{ + Type: protocol.MessageTypeDDNSConfig, + Data: data, + } + + msgData, err := json.Marshal(msg) + if err != nil { + return err + } + + return s.wsManager.SendToClient(config.AgentID, msgData) +} diff --git a/internal/service/property_service.go b/internal/service/property_service.go index 85b3bd4..83bdc6f 100644 --- a/internal/service/property_service.go +++ b/internal/service/property_service.go @@ -23,6 +23,8 @@ const ( PropertyIDMetricsConfig = "metrics_config" // PropertyIDAlertConfig 告警配置的固定 ID PropertyIDAlertConfig = "alert_config" + // PropertyIDDNSProviders DNS 服务商配置的固定 ID + PropertyIDDNSProviders = "dns_providers" ) type PropertyService struct { @@ -158,6 +160,80 @@ func (s *PropertyService) SetAlertConfig(ctx context.Context, config models.Aler return s.Set(ctx, PropertyIDAlertConfig, "告警配置", config) } +// GetDNSProviderConfigs 获取 DNS 服务商配置列表 +func (s *PropertyService) GetDNSProviderConfigs(ctx context.Context) ([]models.DNSProviderConfig, error) { + var providers []models.DNSProviderConfig + err := s.GetValue(ctx, PropertyIDDNSProviders, &providers) + if err != nil { + return nil, fmt.Errorf("获取 DNS 服务商配置失败: %w", err) + } + return providers, nil +} + +// GetDNSProviderByType 根据 Provider 类型获取单个配置 +func (s *PropertyService) GetDNSProviderByType(ctx context.Context, providerType string) (*models.DNSProviderConfig, error) { + providers, err := s.GetDNSProviderConfigs(ctx) + if err != nil { + return nil, err + } + + for _, provider := range providers { + if provider.Provider == providerType { + return &provider, nil + } + } + return nil, fmt.Errorf("未找到 DNS 服务商配置: %s", providerType) +} + +// SetDNSProviderConfigs 设置 DNS 服务商配置列表 +func (s *PropertyService) SetDNSProviderConfigs(ctx context.Context, providers []models.DNSProviderConfig) error { + return s.Set(ctx, PropertyIDDNSProviders, "DNS 服务商配置", providers) +} + +// UpsertDNSProvider 创建或更新单个 DNS 服务商配置(每种类型只允许一个) +func (s *PropertyService) UpsertDNSProvider(ctx context.Context, newProvider models.DNSProviderConfig) error { + providers, err := s.GetDNSProviderConfigs(ctx) + if err != nil && err.Error() != "获取 DNS 服务商配置失败: record not found" { + return err + } + + // 查找是否已存在该类型的配置 + found := false + for i, provider := range providers { + if provider.Provider == newProvider.Provider { + // 更新现有配置 + providers[i] = newProvider + found = true + break + } + } + + // 如果不存在,添加新配置 + if !found { + providers = append(providers, newProvider) + } + + return s.SetDNSProviderConfigs(ctx, providers) +} + +// DeleteDNSProvider 删除指定类型的 DNS 服务商配置 +func (s *PropertyService) DeleteDNSProvider(ctx context.Context, providerType string) error { + providers, err := s.GetDNSProviderConfigs(ctx) + if err != nil { + return err + } + + // 过滤掉指定类型的配置 + var newProviders []models.DNSProviderConfig + for _, provider := range providers { + if provider.Provider != providerType { + newProviders = append(newProviders, provider) + } + } + + return s.SetDNSProviderConfigs(ctx, newProviders) +} + // defaultPropertyConfig 默认配置项定义 type defaultPropertyConfig struct { ID string @@ -219,6 +295,11 @@ func (s *PropertyService) InitializeDefaultConfigs(ctx context.Context) error { }, }, }, + { + ID: PropertyIDDNSProviders, + Name: "DNS 服务商配置", + Value: []models.DNSProviderConfig{}, // 默认为空数组 + }, } // 遍历并初始化每个配置 diff --git a/internal/wire.go b/internal/wire.go index ea466d5..dc48f9a 100644 --- a/internal/wire.go +++ b/internal/wire.go @@ -29,6 +29,7 @@ func InitializeApp(logger *zap.Logger, db *gorm.DB, cfg *config.AppConfig) (*App service.NewTamperService, service.NewMetricService, service.NewGeoIPService, + service.NewDDNSService, service.NewNotifier, // WebSocket Manager @@ -36,6 +37,8 @@ func InitializeApp(logger *zap.Logger, db *gorm.DB, cfg *config.AppConfig) (*App // Repositories repo.NewTamperRepo, + repo.NewDDNSConfigRepo, + repo.NewDDNSRecordRepo, // Handlers handler.NewAgentHandler, @@ -45,6 +48,8 @@ func InitializeApp(logger *zap.Logger, db *gorm.DB, cfg *config.AppConfig) (*App handler.NewApiKeyHandler, handler.NewAccountHandler, handler.NewTamperHandler, + handler.NewDNSProviderHandler, + handler.NewDDNSHandler, // App Components wire.Struct(new(AppComponents), "*"), @@ -54,13 +59,15 @@ func InitializeApp(logger *zap.Logger, db *gorm.DB, cfg *config.AppConfig) (*App // AppComponents 应用组件 type AppComponents struct { - AccountHandler *handler.AccountHandler - AgentHandler *handler.AgentHandler - ApiKeyHandler *handler.ApiKeyHandler - AlertHandler *handler.AlertHandler - PropertyHandler *handler.PropertyHandler - MonitorHandler *handler.MonitorHandler - TamperHandler *handler.TamperHandler + AccountHandler *handler.AccountHandler + AgentHandler *handler.AgentHandler + ApiKeyHandler *handler.ApiKeyHandler + AlertHandler *handler.AlertHandler + PropertyHandler *handler.PropertyHandler + MonitorHandler *handler.MonitorHandler + TamperHandler *handler.TamperHandler + DNSProviderHandler *handler.DNSProviderHandler + DDNSHandler *handler.DDNSHandler AgentService *service.AgentService MetricService *service.MetricService @@ -69,6 +76,7 @@ type AppComponents struct { MonitorService *service.MonitorService ApiKeyService *service.ApiKeyService TamperService *service.TamperService + DDNSService *service.DDNSService WSManager *websocket.Manager } diff --git a/internal/wire_gen.go b/internal/wire_gen.go index 19bb31c..64ec7c6 100644 --- a/internal/wire_gen.go +++ b/internal/wire_gen.go @@ -37,7 +37,10 @@ func InitializeApp(logger *zap.Logger, db *gorm.DB, cfg *config.AppConfig) (*App monitorService := service.NewMonitorService(logger, db, manager) tamperRepo := repo.NewTamperRepo(db) tamperService := service.NewTamperService(logger, tamperRepo, manager) - agentHandler := handler.NewAgentHandler(logger, agentService, metricService, monitorService, tamperService, manager) + ddnsConfigRepo := repo.NewDDNSConfigRepo(db) + ddnsRecordRepo := repo.NewDDNSRecordRepo(db) + ddnsService := service.NewDDNSService(logger, ddnsConfigRepo, ddnsRecordRepo, propertyService, manager) + agentHandler := handler.NewAgentHandler(logger, agentService, metricService, monitorService, tamperService, ddnsService, manager) apiKeyHandler := handler.NewApiKeyHandler(logger, apiKeyService) notifier := service.NewNotifier(logger) alertService := service.NewAlertService(logger, db, propertyService, notifier) @@ -45,22 +48,27 @@ func InitializeApp(logger *zap.Logger, db *gorm.DB, cfg *config.AppConfig) (*App propertyHandler := handler.NewPropertyHandler(logger, propertyService, notifier) monitorHandler := handler.NewMonitorHandler(logger, monitorService, agentService) tamperHandler := handler.NewTamperHandler(logger, tamperService) + dnsProviderHandler := handler.NewDNSProviderHandler(logger, propertyService) + ddnsHandler := handler.NewDDNSHandler(logger, ddnsService) appComponents := &AppComponents{ - AccountHandler: accountHandler, - AgentHandler: agentHandler, - ApiKeyHandler: apiKeyHandler, - AlertHandler: alertHandler, - PropertyHandler: propertyHandler, - MonitorHandler: monitorHandler, - TamperHandler: tamperHandler, - AgentService: agentService, - MetricService: metricService, - AlertService: alertService, - PropertyService: propertyService, - MonitorService: monitorService, - ApiKeyService: apiKeyService, - TamperService: tamperService, - WSManager: manager, + AccountHandler: accountHandler, + AgentHandler: agentHandler, + ApiKeyHandler: apiKeyHandler, + AlertHandler: alertHandler, + PropertyHandler: propertyHandler, + MonitorHandler: monitorHandler, + TamperHandler: tamperHandler, + DNSProviderHandler: dnsProviderHandler, + DDNSHandler: ddnsHandler, + AgentService: agentService, + MetricService: metricService, + AlertService: alertService, + PropertyService: propertyService, + MonitorService: monitorService, + ApiKeyService: apiKeyService, + TamperService: tamperService, + DDNSService: ddnsService, + WSManager: manager, } return appComponents, nil } @@ -69,13 +77,15 @@ func InitializeApp(logger *zap.Logger, db *gorm.DB, cfg *config.AppConfig) (*App // AppComponents 应用组件 type AppComponents struct { - AccountHandler *handler.AccountHandler - AgentHandler *handler.AgentHandler - ApiKeyHandler *handler.ApiKeyHandler - AlertHandler *handler.AlertHandler - PropertyHandler *handler.PropertyHandler - MonitorHandler *handler.MonitorHandler - TamperHandler *handler.TamperHandler + AccountHandler *handler.AccountHandler + AgentHandler *handler.AgentHandler + ApiKeyHandler *handler.ApiKeyHandler + AlertHandler *handler.AlertHandler + PropertyHandler *handler.PropertyHandler + MonitorHandler *handler.MonitorHandler + TamperHandler *handler.TamperHandler + DNSProviderHandler *handler.DNSProviderHandler + DDNSHandler *handler.DDNSHandler AgentService *service.AgentService MetricService *service.MetricService @@ -84,6 +94,7 @@ type AppComponents struct { MonitorService *service.MonitorService ApiKeyService *service.ApiKeyService TamperService *service.TamperService + DDNSService *service.DDNSService WSManager *websocket.Manager } diff --git a/pkg/agent/audit/asset_user.go b/pkg/agent/audit/asset_user.go index b657026..6836873 100644 --- a/pkg/agent/audit/asset_user.go +++ b/pkg/agent/audit/asset_user.go @@ -2,9 +2,9 @@ package audit import ( "bufio" - "fmt" "os" "path/filepath" + "strconv" "strings" "time" @@ -216,7 +216,7 @@ func (uac *UserAssetsCollector) collectCurrentLogins() []protocol.LoginSession { idleTime := 0 if idleStr != "-" { // 简化处理,不解析复杂格式 - fmt.Sscanf(idleStr, "%d", &idleTime) + idleTime = parseInt(idleStr) } session := protocol.LoginSession{ @@ -458,8 +458,7 @@ func (uac *UserAssetsCollector) collectSSHConfig() *protocol.SSHConfig { // parseInt 解析整数 func parseInt(s string) int { - var result int - fmt.Sscanf(s, "%d", &result) + result, _ := strconv.Atoi(s) return result } diff --git a/pkg/agent/collector/cpu.go b/pkg/agent/collector/cpu.go index 0f5be84..3f7babc 100644 --- a/pkg/agent/collector/cpu.go +++ b/pkg/agent/collector/cpu.go @@ -64,17 +64,10 @@ func (c *CPUCollector) Collect() (*protocol.CPUData, error) { cpuPercent = percentages[0] } - // 获取每个核心的使用率 - perCorePercent, err := cpu.Percent(time.Second, true) - if err != nil { - perCorePercent = nil // 如果失败,不影响整体数据 - } - return &protocol.CPUData{ LogicalCores: c.logicalCores, PhysicalCores: c.physicalCores, ModelName: c.modelName, UsagePercent: cpuPercent, - PerCore: perCorePercent, }, nil } diff --git a/pkg/agent/collector/ddns.go b/pkg/agent/collector/ddns.go new file mode 100644 index 0000000..2a7ed23 --- /dev/null +++ b/pkg/agent/collector/ddns.go @@ -0,0 +1,227 @@ +package collector + +import ( + "fmt" + "io" + "net" + "net/http" + "regexp" + "strings" + "time" + + "github.com/dushixiang/pika/internal/protocol" +) + +// 默认 IPv4 API 列表 +var defaultIPv4APIs = []string{ + "https://myip.ipip.net", + "https://ddns.oray.com/checkip", + "https://ip.3322.net", + "https://4.ipw.cn", + "https://v4.yinghualuo.cn/bejson", +} + +// 默认 IPv6 API 列表 +var defaultIPv6APIs = []string{ + "https://speed.neu6.edu.cn/getIP.php", + "https://v6.ident.me", + "https://6.ipw.cn", + "https://v6.yinghualuo.cn/bejson", +} + +// IPv4 正则表达式 +var ipv4Regex = regexp.MustCompile(`(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})`) + +// IPv6 正则表达式 +var ipv6Regex = regexp.MustCompile(`([0-9a-fA-F:]+:+[0-9a-fA-F:]+)`) + +// DDNSCollector DDNS IP 地址采集器 +type DDNSCollector struct { + config *protocol.DDNSConfigData +} + +// NewDDNSCollector 创建 DDNS 采集器 +func NewDDNSCollector(config *protocol.DDNSConfigData) *DDNSCollector { + return &DDNSCollector{ + config: config, + } +} + +// UpdateConfig 更新配置 +func (d *DDNSCollector) UpdateConfig(config *protocol.DDNSConfigData) { + d.config = config +} + +// Collect 采集 IP 地址 +func (d *DDNSCollector) Collect() (*protocol.DDNSIPReportData, error) { + if d.config == nil || !d.config.Enabled { + return nil, fmt.Errorf("DDNS 未启用") + } + + data := &protocol.DDNSIPReportData{} + + // 采集 IPv4 + if d.config.EnableIPv4 { + ipv4, err := d.getIP(d.config.IPv4GetMethod, d.config.IPv4GetValue, false) + if err == nil && ipv4 != "" { + data.IPv4 = ipv4 + } + } + + // 采集 IPv6 + if d.config.EnableIPv6 { + ipv6, err := d.getIP(d.config.IPv6GetMethod, d.config.IPv6GetValue, true) + if err == nil && ipv6 != "" { + data.IPv6 = ipv6 + } + } + + return data, nil +} + +// getIP 根据配置获取 IP 地址 +func (d *DDNSCollector) getIP(method, value string, isIPv6 bool) (string, error) { + switch method { + case "api": + return d.GetIPFromAPI(value, isIPv6) + case "interface": + return d.GetIPFromInterface(value, isIPv6) + default: + return "", fmt.Errorf("不支持的获取方式: %s", method) + } +} + +// GetIPFromAPI 通过 API 获取 IP 地址(支持轮询多个 API) +func (d *DDNSCollector) GetIPFromAPI(apiURL string, isIPv6 bool) (string, error) { + var apiList []string + + if apiURL == "" { + // 使用默认 API 列表 + if isIPv6 { + apiList = defaultIPv6APIs + } else { + apiList = defaultIPv4APIs + } + } else { + // 使用指定的 API + apiList = []string{apiURL} + } + + client := &http.Client{ + Timeout: 10 * time.Second, + } + + var lastErr error + // 轮询 API 列表,直到成功获取 IP + for _, api := range apiList { + ip, err := d.fetchIPFromAPI(client, api, isIPv6) + if err == nil { + return ip, nil + } + lastErr = err + } + + if lastErr != nil { + return "", fmt.Errorf("所有 API 请求均失败,最后错误: %w", lastErr) + } + return "", fmt.Errorf("未能获取 IP 地址") +} + +// fetchIPFromAPI 从单个 API 获取 IP 地址 +func (d *DDNSCollector) fetchIPFromAPI(client *http.Client, apiURL string, isIPv6 bool) (string, error) { + resp, err := client.Get(apiURL) + if err != nil { + return "", fmt.Errorf("API 请求失败: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("API 返回错误状态: %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("读取响应失败: %w", err) + } + + // 使用正则表达式提取 IP 地址 + var regex *regexp.Regexp + if isIPv6 { + regex = ipv6Regex + } else { + regex = ipv4Regex + } + + matches := regex.FindStringSubmatch(string(body)) + if len(matches) < 2 { + return "", fmt.Errorf("响应中未找到有效的 IP 地址: %s", string(body)) + } + + ip := strings.TrimSpace(matches[1]) + + // 验证 IP 格式 + if !isValidIP(ip, isIPv6) { + return "", fmt.Errorf("无效的 IP 地址: %s", ip) + } + + return ip, nil +} + +// GetIPFromInterface 从网卡获取 IP 地址 +func (d *DDNSCollector) GetIPFromInterface(interfaceName string, isIPv6 bool) (string, error) { + if interfaceName == "" { + return "", fmt.Errorf("网卡名称不能为空") + } + + iface, err := net.InterfaceByName(interfaceName) + if err != nil { + return "", fmt.Errorf("获取网卡失败: %w", err) + } + + addrs, err := iface.Addrs() + if err != nil { + return "", fmt.Errorf("获取网卡地址失败: %w", err) + } + + for _, addr := range addrs { + ipNet, ok := addr.(*net.IPNet) + if !ok { + continue + } + + ip := ipNet.IP + // 过滤本地回环地址 + if ip.IsLoopback() { + continue + } + + // 根据 IPv4/IPv6 筛选 + if isIPv6 { + if ip.To4() == nil && ip.To16() != nil { + // 过滤链路本地地址 + if !ip.IsLinkLocalUnicast() { + return ip.String(), nil + } + } + } else { + if ip.To4() != nil { + return ip.String(), nil + } + } + } + + return "", fmt.Errorf("未找到符合条件的 IP 地址") +} + +// isValidIP 验证 IP 地址格式 +func isValidIP(ipStr string, isIPv6 bool) bool { + ip := net.ParseIP(ipStr) + if ip == nil { + return false + } + + if isIPv6 { + return ip.To4() == nil && ip.To16() != nil + } + return ip.To4() != nil +} diff --git a/pkg/agent/collector/manager.go b/pkg/agent/collector/manager.go index 413274f..46cca2a 100644 --- a/pkg/agent/collector/manager.go +++ b/pkg/agent/collector/manager.go @@ -24,6 +24,7 @@ type Manager struct { temperatureCollector *TemperatureCollector gpuCollector *GPUCollector monitorCollector *MonitorCollector + ddnsCollector *DDNSCollector } // NewManager 创建采集器管理器 @@ -39,6 +40,7 @@ func NewManager(cfg *config.Config) *Manager { temperatureCollector: NewTemperatureCollector(), gpuCollector: NewGPUCollector(), monitorCollector: NewMonitorCollector(), + ddnsCollector: nil, // DDNS 采集器需要配置后才能初始化 } } @@ -136,6 +138,49 @@ func (m *Manager) CollectAndSendMonitor(conn WebSocketWriter, items []protocol.M return m.sendMetrics(conn, protocol.MetricTypeMonitor, monitorDataList) } +// UpdateDDNSConfig 更新 DDNS 配置 +func (m *Manager) UpdateDDNSConfig(config *protocol.DDNSConfigData) { + if config == nil || !config.Enabled { + m.ddnsCollector = nil + return + } + + if m.ddnsCollector == nil { + m.ddnsCollector = NewDDNSCollector(config) + } else { + m.ddnsCollector.UpdateConfig(config) + } +} + +// CollectAndSendDDNSIP 采集并发送 DDNS IP 地址 +func (m *Manager) CollectAndSendDDNSIP(conn WebSocketWriter) error { + if m.ddnsCollector == nil { + return nil // DDNS 未启用,静默返回 + } + + ipData, err := m.ddnsCollector.Collect() + if err != nil { + return err + } + + // 只有当至少有一个 IP 地址时才发送 + if ipData.IPv4 == "" && ipData.IPv6 == "" { + return nil + } + + dataBytes, err := json.Marshal(ipData) + if err != nil { + return err + } + + msg := protocol.Message{ + Type: protocol.MessageTypeDDNSIPReport, + Data: dataBytes, + } + + return conn.WriteJSON(msg) +} + // sendMetrics 发送指标数据 func (m *Manager) sendMetrics(conn WebSocketWriter, metricType protocol.MetricType, data interface{}) error { dataBytes, err := json.Marshal(data) @@ -160,3 +205,19 @@ func (m *Manager) sendMetrics(conn WebSocketWriter, metricType protocol.MetricTy return conn.WriteJSON(msg) } + +// GetPublicIP 通过 API 获取公网 IP 地址 +func (m *Manager) GetPublicIP(apiURL string, isIPv6 bool) (string, error) { + collector := NewDDNSCollector(&protocol.DDNSConfigData{ + Enabled: true, + }) + return collector.GetIPFromAPI(apiURL, isIPv6) +} + +// GetInterfaceIP 从网络接口获取 IP 地址 +func (m *Manager) GetInterfaceIP(interfaceName string, isIPv6 bool) (string, error) { + collector := NewDDNSCollector(&protocol.DDNSConfigData{ + Enabled: true, + }) + return collector.GetIPFromInterface(interfaceName, isIPv6) +} diff --git a/pkg/agent/service/agent.go b/pkg/agent/service/agent.go index b5263b0..6f4c186 100644 --- a/pkg/agent/service/agent.go +++ b/pkg/agent/service/agent.go @@ -271,6 +271,8 @@ func (a *Agent) readLoop(conn *websocket.Conn, done chan struct{}) error { go a.handleMonitorConfig(msg.Data) case protocol.MessageTypeTamperProtect: go a.handleTamperProtect(msg.Data) + case protocol.MessageTypeDDNSConfig: + go a.handleDDNSConfig(msg.Data) default: // 忽略其他类型 } @@ -765,3 +767,96 @@ func (a *Agent) tamperAlertLoop(ctx context.Context, conn *safeConn, done chan s } } } + +// handleDDNSConfig 处理 DDNS 配置(服务端定时下发) +func (a *Agent) handleDDNSConfig(data json.RawMessage) { + var ddnsConfig protocol.DDNSConfigData + if err := json.Unmarshal(data, &ddnsConfig); err != nil { + log.Printf("⚠️ 解析 DDNS 配置失败: %v", err) + return + } + + if !ddnsConfig.Enabled { + log.Println("ℹ️ DDNS 已禁用,跳过 IP 检查") + return + } + + conn := a.getActiveConn() + manager := a.getCollectorManager() + if conn == nil || manager == nil { + log.Println("⚠️ 当前连接未就绪,无法执行 DDNS IP 检查") + return + } + + log.Println("📥 收到 DDNS 配置检查请求,开始采集 IP 地址") + + // 采集 IP 地址并上报 + if err := a.collectAndSendDDNSIP(conn, manager, &ddnsConfig); err != nil { + log.Printf("⚠️ DDNS IP 采集失败: %v", err) + } else { + log.Println("✅ DDNS IP 地址已上报") + } +} + +// collectAndSendDDNSIP 采集并发送 DDNS IP 地址 +func (a *Agent) collectAndSendDDNSIP(conn *safeConn, manager *collector.Manager, config *protocol.DDNSConfigData) error { + var ipReport protocol.DDNSIPReportData + + // 采集 IPv4 + if config.EnableIPv4 { + ipv4, err := a.getIPAddress(manager, config.IPv4GetMethod, config.IPv4GetValue, false) + if err != nil { + log.Printf("⚠️ 获取 IPv4 失败: %v", err) + } else { + ipReport.IPv4 = ipv4 + log.Printf("✅ 获取 IPv4: %s", ipv4) + } + } + + // 采集 IPv6 + if config.EnableIPv6 { + ipv6, err := a.getIPAddress(manager, config.IPv6GetMethod, config.IPv6GetValue, true) + if err != nil { + log.Printf("⚠️ 获取 IPv6 失败: %v", err) + } else { + ipReport.IPv6 = ipv6 + log.Printf("✅ 获取 IPv6: %s", ipv6) + } + } + + // 如果没有获取到任何 IP,返回错误 + if ipReport.IPv4 == "" && ipReport.IPv6 == "" { + return fmt.Errorf("未获取到任何 IP 地址") + } + + // 序列化并发送 + data, err := json.Marshal(ipReport) + if err != nil { + return fmt.Errorf("序列化 IP 报告失败: %w", err) + } + + msg := protocol.Message{ + Type: protocol.MessageTypeDDNSIPReport, + Data: data, + } + + if err := conn.WriteJSON(msg); err != nil { + return fmt.Errorf("发送 IP 报告失败: %w", err) + } + + return nil +} + +// getIPAddress 根据配置获取 IP 地址 +func (a *Agent) getIPAddress(manager *collector.Manager, method, value string, isIPv6 bool) (string, error) { + switch method { + case "api": + // 使用 API 获取公网 IP + return manager.GetPublicIP(value, isIPv6) + case "interface": + // 从网络接口获取 IP + return manager.GetInterfaceIP(value, isIPv6) + default: + return "", fmt.Errorf("不支持的 IP 获取方式: %s", method) + } +} diff --git a/web/src/api/ddns.ts b/web/src/api/ddns.ts new file mode 100644 index 0000000..e41b9da --- /dev/null +++ b/web/src/api/ddns.ts @@ -0,0 +1,61 @@ +import {del, get, post, put} from './request'; +import type {CreateDDNSConfigRequest, DDNSConfig, DDNSRecord, UpdateDDNSConfigRequest} from '@/types/ddns'; + +export interface DDNSConfigListResponse { + items: DDNSConfig[]; + total: number; +} + +export interface DDNSRecordListResponse { + items: DDNSRecord[]; + total: number; +} + +// 获取 DDNS 配置列表(分页) +export const getDDNSConfigs = (page: number, size: number, name?: string, agentId?: string) => { + const params = new URLSearchParams(); + params.append('page', page.toString()); + params.append('size', size.toString()); + if (name) { + params.append('name', name); + } + if (agentId) { + params.append('agentId', agentId); + } + return get(`/admin/ddns?${params.toString()}`); +}; + +// 创建 DDNS 配置 +export const createDDNSConfig = (data: CreateDDNSConfigRequest) => { + return post('/admin/ddns', data); +}; + +// 获取 DDNS 配置详情 +export const getDDNSConfig = (id: string) => { + return get(`/admin/ddns/${id}`); +}; + +// 更新 DDNS 配置 +export const updateDDNSConfig = (id: string, data: UpdateDDNSConfigRequest) => { + return put<{ message: string }>(`/admin/ddns/${id}`, data); +}; + +// 删除 DDNS 配置 +export const deleteDDNSConfig = (id: string) => { + return del<{ message: string }>(`/admin/ddns/${id}`); +}; + +// 启用 DDNS 配置 +export const enableDDNSConfig = (id: string) => { + return post<{ message: string }>(`/admin/ddns/${id}/enable`, {}); +}; + +// 禁用 DDNS 配置 +export const disableDDNSConfig = (id: string) => { + return post<{ message: string }>(`/admin/ddns/${id}/disable`, {}); +}; + +// 获取 DDNS 更新记录 +export const getDDNSRecords = (id: string) => { + return get(`/admin/ddns/${id}/records`); +}; diff --git a/web/src/api/dnsProvider.ts b/web/src/api/dnsProvider.ts new file mode 100644 index 0000000..2d8235f --- /dev/null +++ b/web/src/api/dnsProvider.ts @@ -0,0 +1,17 @@ +import {del, get, post} from './request'; +import type {DNSProviderConfig, UpsertDNSProviderRequest} from '@/types/ddns'; + +// 获取所有 DNS Provider 配置 +export const getDNSProviders = () => { + return get('/admin/dns-providers'); +}; + +// 创建或更新 DNS Provider 配置 +export const upsertDNSProvider = (data: UpsertDNSProviderRequest) => { + return post<{message: string}>('/admin/dns-providers', data); +}; + +// 删除 DNS Provider 配置 +export const deleteDNSProvider = (provider: string) => { + return del<{message: string}>(`/admin/dns-providers/${provider}`); +}; diff --git a/web/src/components/PageHeader.tsx b/web/src/components/PageHeader.tsx index 6590a16..86114a6 100644 --- a/web/src/components/PageHeader.tsx +++ b/web/src/components/PageHeader.tsx @@ -8,6 +8,7 @@ export interface Action { icon?: React.ReactElement; type?: 'default' | 'primary'; onClick: () => void; + danger?: boolean; } interface PageHeaderProps { @@ -34,6 +35,7 @@ export const PageHeader: React.FC = ({title, description, actio type={action.type || 'default'} icon={action.icon} onClick={action.onClick} + danger={action.danger} > {action.label} diff --git a/web/src/pages/AdminLayout/index.tsx b/web/src/pages/AdminLayout/index.tsx index 9284b49..9098fd7 100644 --- a/web/src/pages/AdminLayout/index.tsx +++ b/web/src/pages/AdminLayout/index.tsx @@ -2,7 +2,7 @@ import {type JSX, useEffect, useMemo, useState} from 'react'; import {Outlet, useLocation, useNavigate} from 'react-router-dom'; import type {MenuProps} from 'antd'; import {App, Avatar, Button, Dropdown, Space} from 'antd'; -import {Activity, AlertTriangle, BookOpen, Eye, Key, LogOut, Server, Settings, User as UserIcon} from 'lucide-react'; +import {Activity, AlertTriangle, BookOpen, Eye, Globe, Key, LogOut, Server, Settings, User as UserIcon} from 'lucide-react'; import {logout} from '@/api/auth.ts'; import type {User} from '@/types'; import {cn} from '@/lib/utils'; @@ -45,6 +45,12 @@ const AdminLayout = () => { path: '/admin/monitors', icon: , }, + { + key: 'ddns', + label: 'DDNS', + path: '/admin/ddns', + icon: , + }, { key: 'alert-records', label: '告警记录', diff --git a/web/src/pages/Agents/AgentList.tsx b/web/src/pages/Agents/AgentList.tsx index ff4046c..d3f199e 100644 --- a/web/src/pages/Agents/AgentList.tsx +++ b/web/src/pages/Agents/AgentList.tsx @@ -1,10 +1,10 @@ -import {useRef, useState, useEffect} from 'react'; +import {useEffect, useRef, useState} from 'react'; import {useNavigate} from 'react-router-dom'; import type {ActionType, ProColumns} from '@ant-design/pro-components'; import {ProTable} from '@ant-design/pro-components'; -import {App, Button, DatePicker, Divider, Dropdown, Form, Input, Modal, Select, Space, Tag} from 'antd'; import type {MenuProps} from 'antd'; -import {Edit, Eye, RefreshCw, Plus, Shield, Trash2, MoreVertical} from 'lucide-react'; +import {App, Button, DatePicker, Divider, Dropdown, Form, Input, Modal, Select, Space, Tag} from 'antd'; +import {Edit, Eye, MoreVertical, Plus, RefreshCw, Shield, Trash2} from 'lucide-react'; import {deleteAgent, getAgentPaging, getTags, updateAgentInfo} from '@/api/agent.ts'; import type {Agent} from '@/types'; import {getErrorMessage} from '@/lib/utils'; @@ -129,7 +129,7 @@ const AgentList = () => { <> {record.tags && record.tags?.length > 0 ? ( record.tags?.map((tag, index) => ( - + {tag} )) @@ -374,7 +374,7 @@ const AgentList = () => { + + + {/* 新建模式才显示探针选择器 */} + {!isEditMode && ( + + + {providers.map((p) => ( + + {providerNames[p.provider]} + + ))} + + + +
+ {/* IPv4 配置卡片 */} +
+
+

IPv4 配置

+ + + +
+ + {enableIpv4 && ( + <> + { + if (!value || value.trim() === '') { + return Promise.reject('请输入至少一个 IPv4 域名'); + } + return Promise.resolve(); + } + } + ]} + extra="每行输入一个域名,用于 IPv4(A 记录)" + > + + + + + + + + +
默认 IPv4 API 列表:
+ {defaultIPv4APIs.map((api, index) => ( +
{api}
+ ))} +
+ } + > + + + + )} +
+ + {/* IPv6 配置卡片 */} +
+
+

IPv6 配置

+ + + +
+ + {enableIpv6 && ( + <> + { + if (!value || value.trim() === '') { + return Promise.reject('请输入至少一个 IPv6 域名'); + } + return Promise.resolve(); + } + } + ]} + extra="每行输入一个域名,用于 IPv6(AAAA 记录)" + > + + + + + + + + +
默认 IPv6 API 列表:
+ {defaultIPv6APIs.map((api, index) => ( +
{api}
+ ))} +
+ } + > + + + + )} + + + + + ); +}; + +export default DDNSModal; diff --git a/web/src/pages/DDNS/components/DNSProviderModal.tsx b/web/src/pages/DDNS/components/DNSProviderModal.tsx new file mode 100644 index 0000000..d183064 --- /dev/null +++ b/web/src/pages/DDNS/components/DNSProviderModal.tsx @@ -0,0 +1,314 @@ +import {useState, useEffect} from 'react'; +import {App, Modal, Form, Input, Switch, Tabs, Button, Descriptions, Tag} from 'antd'; +import {getDNSProviders, upsertDNSProvider, deleteDNSProvider} from '@/api/dnsProvider'; +import type {DNSProviderConfig} from '@/types/ddns'; +import {getErrorMessage} from '@/lib/utils'; + +interface DNSProviderModalProps { + open: boolean; + onCancel: () => void; + onSuccess: () => void; +} + +const DNSProviderModal = ({open, onCancel, onSuccess}: DNSProviderModalProps) => { + const {message: messageApi, modal} = App.useApp(); + const [loading, setLoading] = useState(false); + const [providers, setProviders] = useState([]); + const [activeTab, setActiveTab] = useState('aliyun'); + const [form] = Form.useForm(); + + useEffect(() => { + if (open) { + loadProviders(); + } + }, [open]); + + const loadProviders = async () => { + try { + const response = await getDNSProviders(); + const data = response.data || []; + setProviders(data); + + // 只设置启用状态,不设置脱敏后的配置值 + data.forEach((provider) => { + form.setFieldsValue({ + [`${provider.provider}_enabled`]: provider.enabled, + }); + }); + } catch (error) { + messageApi.error('加载 DNS Provider 配置失败'); + } + }; + + const handleSave = async (providerType: string) => { + try { + // 获取该 provider 需要验证的字段 + const configFields = getConfigFields(providerType); + const fieldsToValidate = [ + `${providerType}_enabled`, + ...configFields.map((field) => `${providerType}_${field}`), + ]; + + // 只验证当前 provider 的字段 + await form.validateFields(fieldsToValidate); + const values = form.getFieldsValue(); + + // 提取该 provider 的配置 + const enabled = values[`${providerType}_enabled`] || false; + const config: Record = {}; + + // 根据不同的 provider 类型提取配置字段 + configFields.forEach((field) => { + const value = values[`${providerType}_${field}`]; + if (value) { + config[field] = value; + } + }); + + // 验证必须有完整的配置字段 + const missingFields = configFields.filter((field) => !config[field]); + if (missingFields.length > 0) { + messageApi.error('请填写完整的配置信息'); + return; + } + + setLoading(true); + await upsertDNSProvider({ + provider: providerType as any, + enabled, + config, + }); + + messageApi.success('保存成功'); + await loadProviders(); + onSuccess(); + } catch (error: any) { + if (error.errorFields) { + // 表单验证失败 + return; + } + messageApi.error(getErrorMessage(error, '保存失败')); + console.error('保存 DNS Provider 失败:', error); + } finally { + setLoading(false); + } + }; + + const handleDelete = (providerType: string) => { + modal.confirm({ + title: '删除 DNS Provider 配置', + content: `确定要删除 ${getProviderName(providerType)} 的配置吗?`, + okButtonProps: {danger: true}, + onOk: async () => { + try { + await deleteDNSProvider(providerType); + messageApi.success('删除成功'); + loadProviders(); + onSuccess(); + + // 清空表单 + const configFields = getConfigFields(providerType); + const fieldsToReset = [ + `${providerType}_enabled`, + ...configFields.map((field) => `${providerType}_${field}`), + ]; + form.resetFields(fieldsToReset); + } catch (error: unknown) { + messageApi.error(getErrorMessage(error, '删除失败')); + } + }, + }); + }; + + const getConfigFields = (providerType: string): string[] => { + switch (providerType) { + case 'aliyun': + return ['accessKeyId', 'accessKeySecret']; + case 'tencentcloud': + return ['secretId', 'secretKey']; + case 'cloudflare': + return ['apiToken']; + case 'huaweicloud': + return ['accessKeyId', 'secretAccessKey', 'region']; + default: + return []; + } + }; + + const getProviderName = (providerType: string): string => { + const names: Record = { + aliyun: '阿里云', + tencentcloud: '腾讯云', + cloudflare: 'Cloudflare', + huaweicloud: '华为云', + }; + return names[providerType] || providerType; + }; + + const getProviderConfig = (providerType: string): DNSProviderConfig | undefined => { + return providers.find((p) => p.provider === providerType); + }; + + const isProviderConfigured = (providerType: string): boolean => { + return providers.some((p) => p.provider === providerType); + }; + + const renderProviderForm = (providerType: string) => { + const providerConfig = getProviderConfig(providerType); + const configured = !!providerConfig; + + // 如果已配置,显示只读信息 + if (configured && providerConfig) { + return ( +
+ + + + {providerConfig.enabled ? '已启用' : '已禁用'} + + + {Object.entries(providerConfig.config).map(([key, value]) => ( + + {value} + + ))} + + +
+ +
+
+ ); + } + + // 未配置时显示表单 + return ( +
+ + + + + {providerType === 'aliyun' && ( + <> + + + + + + + + )} + + {providerType === 'tencentcloud' && ( + <> + + + + + + + + )} + + {providerType === 'cloudflare' && ( + + + + )} + + {providerType === 'huaweicloud' && ( + <> + + + + + + + + + + + )} + + +
+ ); + }; + + const tabItems = [ + { + key: 'aliyun', + label: '阿里云', + children: renderProviderForm('aliyun'), + }, + { + key: 'tencentcloud', + label: '腾讯云', + children: renderProviderForm('tencentcloud'), + }, + { + key: 'cloudflare', + label: 'Cloudflare', + children: renderProviderForm('cloudflare'), + }, + { + key: 'huaweicloud', + label: '华为云', + children: renderProviderForm('huaweicloud'), + }, + ]; + + return ( + + + + ); +}; + +export default DNSProviderModal; diff --git a/web/src/pages/DDNS/components/RecordsDrawer.tsx b/web/src/pages/DDNS/components/RecordsDrawer.tsx new file mode 100644 index 0000000..1698f4c --- /dev/null +++ b/web/src/pages/DDNS/components/RecordsDrawer.tsx @@ -0,0 +1,154 @@ +import {useState, useEffect} from 'react'; +import {App, Drawer, Table, Tag, Empty} from 'antd'; +import type {ColumnType} from 'antd/es/table'; +import dayjs from 'dayjs'; +import {getDDNSRecords} from '@/api/ddns'; +import type {DDNSConfig, DDNSRecord} from '@/types/ddns'; +import {ArrowRight, CheckCircle2, XCircle} from 'lucide-react'; + +interface RecordsDrawerProps { + open: boolean; + config: DDNSConfig; + onClose: () => void; +} + +const RecordsDrawer = ({open, config, onClose}: RecordsDrawerProps) => { + const {message: messageApi} = App.useApp(); + const [loading, setLoading] = useState(false); + const [records, setRecords] = useState([]); + + useEffect(() => { + if (open && config) { + loadRecords(); + } + }, [open, config]); + + const loadRecords = async () => { + setLoading(true); + try { + const response = await getDDNSRecords(config.id); + setRecords(response.data.items || []); + } catch (error) { + messageApi.error('加载更新记录失败'); + } finally { + setLoading(false); + } + }; + + const columns: ColumnType[] = [ + { + title: '域名', + dataIndex: 'domain', + key: 'domain', + width: 200, + }, + { + title: '记录类型', + dataIndex: 'recordType', + key: 'recordType', + width: 100, + render: (type: string) => ( + {type} + ), + }, + { + title: 'IP 变化', + key: 'ipChange', + width: 300, + render: (_, record) => ( +
+ {record.oldIp || '(初始)'} + + {record.newIp} +
+ ), + }, + { + title: '状态', + dataIndex: 'status', + key: 'status', + width: 100, + render: (status: string) => ( +
+ {status === 'success' ? ( + <> + + 成功 + + ) : ( + <> + + 失败 + + )} +
+ ), + }, + { + title: '更新时间', + dataIndex: 'createdAt', + key: 'createdAt', + width: 180, + render: (timestamp: number) => + dayjs(timestamp).format('YYYY-MM-DD HH:mm:ss'), + }, + { + title: '错误信息', + dataIndex: 'errorMessage', + key: 'errorMessage', + ellipsis: true, + render: (msg: string) => msg && {msg}, + }, + ]; + + return ( + +
+
+
+ 配置名称: + {config.name} +
+
+ DNS 服务商: + {config.provider} +
+
+ 域名数量: + {(config.domainsIpv4?.length || 0) + (config.domainsIpv6?.length || 0)} 个 +
+
+ 状态: + + {config.enabled ? '启用' : '禁用'} + +
+
+
+ + {records.length === 0 ? ( + + ) : ( + `共 ${total} 条记录`, + }} + scroll={{x: 900}} + /> + )} + + ); +}; + +export default RecordsDrawer; diff --git a/web/src/pages/DDNS/index.tsx b/web/src/pages/DDNS/index.tsx new file mode 100644 index 0000000..23f3b2d --- /dev/null +++ b/web/src/pages/DDNS/index.tsx @@ -0,0 +1,301 @@ +import {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import type {ActionType, ProColumns} from '@ant-design/pro-components'; +import {ProTable} from '@ant-design/pro-components'; +import {App, Button, Divider, Form, Input, Modal, Select, Space, Switch, Tag, Tooltip} from 'antd'; +import {PageHeader} from '@/components'; +import {Globe, Plus, RefreshCw, Settings} from 'lucide-react'; +import dayjs from 'dayjs'; +import {getAgents} from '@/api/agent'; +import type {Agent, DDNSConfig} from '@/types'; +import { + createDDNSConfig, + deleteDDNSConfig, + disableDDNSConfig, + enableDDNSConfig, + getDDNSConfigs, + updateDDNSConfig, +} from '@/api/ddns'; +import {getErrorMessage} from '@/lib/utils'; +import DDNSModal from './components/DDNSModal'; +import RecordsDrawer from './components/RecordsDrawer'; +import DNSProviderModal from './components/DNSProviderModal'; + +const DDNSPage = () => { + const {message, modal} = App.useApp(); + const actionRef = useRef(null); + + const [modalOpen, setModalOpen] = useState(false); + const [recordsDrawerOpen, setRecordsDrawerOpen] = useState(false); + const [providerModalOpen, setProviderModalOpen] = useState(false); + const [selectedConfig, setSelectedConfig] = useState(null); + const [keyword, setKeyword] = useState(''); + + const handleCreate = () => { + setSelectedConfig(null); + setModalOpen(true); + }; + + const handleUpdate = (config: DDNSConfig) => { + setSelectedConfig(config); + setModalOpen(true); + }; + + const handleViewRecords = (config: DDNSConfig) => { + setSelectedConfig(config); + setRecordsDrawerOpen(true); + }; + + const handleToggleStatus = async (config: DDNSConfig) => { + try { + if (config.enabled) { + await disableDDNSConfig(config.id); + message.success('已禁用'); + } else { + await enableDDNSConfig(config.id); + message.success('已启用'); + } + actionRef.current?.reload(); + } catch (error: unknown) { + message.error(getErrorMessage(error, '操作失败')); + } + }; + + const handleDelete = (config: DDNSConfig) => { + modal.confirm({ + title: '删除 DDNS 配置', + content: `确定要删除 DDNS 配置"${config.name}"吗?删除后将无法恢复。`, + okButtonProps: {danger: true}, + onOk: async () => { + try { + await deleteDDNSConfig(config.id); + message.success('删除成功'); + actionRef.current?.reload(); + } catch (error: unknown) { + message.error(getErrorMessage(error, '删除失败')); + } + }, + }); + }; + + const providerNames: Record = { + aliyun: '阿里云', + tencentcloud: '腾讯云', + cloudflare: 'Cloudflare', + huaweicloud: '华为云', + }; + + const columns: ProColumns[] = [ + { + title: '配置名称', + dataIndex: 'name', + render: (_, record) => ( +
+ + {record.name} +
+ ), + }, + { + title: 'DNS 服务商', + dataIndex: 'provider', + width: 120, + render: (provider: string) => ( + {providerNames[provider] || provider} + ), + }, + { + title: '域名', + key: 'domains', + width: 250, + render: (_, record) => { + const allDomains = [ + ...((record.domainsIpv4 || []).map(d => ({ domain: d, type: 'IPv4' }))), + ...((record.domainsIpv6 || []).map(d => ({ domain: d, type: 'IPv6' }))) + ]; + return ( +
+ {allDomains.slice(0, 2).map((item, index) => ( + + {item.domain} + + ))} + {allDomains.length > 2 && ( + `${item.domain} (${item.type})`).join(', ')}> + +{allDomains.length - 2} + + )} +
+ ); + }, + }, + { + title: 'IP 配置', + key: 'ipConfig', + width: 150, + render: (_, record) => ( + + {record.enableIpv4 && IPv4} + {record.enableIpv6 && IPv6} + + ), + }, + { + title: '状态', + dataIndex: 'enabled', + width: 80, + render: (enabled: boolean) => ( + + {enabled ? '启用' : '禁用'} + + ), + }, + { + title: '更新时间', + dataIndex: 'updatedAt', + width: 180, + render: (timestamp: number) => + dayjs(timestamp).format('YYYY-MM-DD HH:mm:ss'), + }, + { + title: '操作', + valueType: 'option', + width: 220, + render: (_, record) => [ + , + , + , + , + ], + }, + ]; + + return ( +
+ , + type: 'primary', + onClick: () => setProviderModalOpen(true), + }, + ]} + /> + + + + + columns={columns} + rowKey="id" + actionRef={actionRef} + search={false} + params={{keyword}} + pagination={{ + defaultPageSize: 10, + showSizeChanger: true, + }} + toolBarRender={() => [ + { + setKeyword(value.trim()); + actionRef.current?.reload(); + }} + style={{width: 260}} + />, + , + ]} + request={async (params) => { + const {current = 1, pageSize = 10, keyword: kw = ''} = params; + try { + const response = await getDDNSConfigs(current, pageSize, kw as string | undefined); + return { + data: response.data.items || [], + success: true, + total: response.data.total, + }; + } catch (error: unknown) { + message.error(getErrorMessage(error, '获取 DDNS 配置列表失败')); + return { + data: [], + success: false, + }; + } + }} + /> + + { + setModalOpen(false); + setSelectedConfig(null); + }} + onSuccess={() => { + setModalOpen(false); + setSelectedConfig(null); + actionRef.current?.reload(); + }} + /> + + {selectedConfig && ( + { + setRecordsDrawerOpen(false); + setSelectedConfig(null); + }} + /> + )} + + setProviderModalOpen(false)} + onSuccess={() => { + // Provider 配置更新后,可能需要刷新 DDNS 列表 + actionRef.current?.reload(); + }} + /> +
+ ); +}; + +export default DDNSPage; diff --git a/web/src/pages/Monitors/MonitorList.tsx b/web/src/pages/Monitors/MonitorList.tsx index ead2259..c6deb6c 100644 --- a/web/src/pages/Monitors/MonitorList.tsx +++ b/web/src/pages/Monitors/MonitorList.tsx @@ -333,12 +333,6 @@ const MonitorList = () => { title="服务监控" description="配置 HTTP/TCP/ICMP 服务可用性检测,集中管理监控策略与探针覆盖范围" actions={[ - { - key: 'refresh', - label: '刷新', - icon: , - onClick: () => actionRef.current?.reload(), - }, { key: 'create', label: '新建监控', @@ -346,6 +340,12 @@ const MonitorList = () => { type: 'primary', onClick: handleCreate, }, + { + key: 'refresh', + label: '刷新', + icon: , + onClick: () => actionRef.current?.reload(), + }, ]} /> @@ -372,9 +372,6 @@ const MonitorList = () => { }} style={{width: 260}} />, - , ]} request={async (params) => { const {current = 1, pageSize = 10, keyword: kw = ''} = params; diff --git a/web/src/pages/Settings/SystemConfig.tsx b/web/src/pages/Settings/SystemConfig.tsx index 5df57f3..5bb2816 100644 --- a/web/src/pages/Settings/SystemConfig.tsx +++ b/web/src/pages/Settings/SystemConfig.tsx @@ -219,17 +219,6 @@ const SystemConfigComponent = () => { {uploading ? '处理中...' : '上传 Logo'} - -
-
💡 使用提示:
-
    -
  • 支持 PNG、JPG、GIF、SVG 等常见图片格式
  • -
  • 建议使用 PNG 格式,支持透明背景
  • -
  • 建议图片尺寸为 256x256 像素
  • -
  • 文件大小不能超过 500KB
  • -
  • 上传后图片将转换为 base64 格式存储
  • -
-
diff --git a/web/src/router/index.tsx b/web/src/router/index.tsx index 0c74bbd..82254ae 100644 --- a/web/src/router/index.tsx +++ b/web/src/router/index.tsx @@ -17,6 +17,7 @@ const ServerDetailPage = lazy(() => import('../pages/Public/ServerDetail')); const PublicMonitorListPage = lazy(() => import('../pages/Public/MonitorList')); const PublicMonitorDetailPage = lazy(() => import('../pages/Public/MonitorDetail')); const MonitorListPage = lazy(() => import('../pages/Monitors/MonitorList')); +const DDNSPage = lazy(() => import('../pages/DDNS')); const AlertRecordListPage = lazy(() => import('../pages/AlertRecords')); const LoadingFallback = () => ( @@ -100,6 +101,10 @@ const router = createBrowserRouter([ path: 'monitors', element: lazyLoad(MonitorListPage), }, + { + path: 'ddns', + element: lazyLoad(DDNSPage), + }, { path: 'alert-records', element: lazyLoad(AlertRecordListPage), diff --git a/web/src/types/ddns.ts b/web/src/types/ddns.ts new file mode 100644 index 0000000..56253cb --- /dev/null +++ b/web/src/types/ddns.ts @@ -0,0 +1,70 @@ +// DNS Provider 配置 +export interface DNSProviderConfig { + provider: 'aliyun' | 'tencentcloud' | 'cloudflare' | 'huaweicloud'; + enabled: boolean; + config: Record; // 已脱敏的配置 +} + +export interface DDNSConfig { + id: string; + agentId: string; + name: string; + enabled: boolean; + provider: 'aliyun' | 'tencentcloud' | 'cloudflare' | 'huaweicloud'; + domainsIpv4: string[]; // IPv4 域名列表 + domainsIpv6: string[]; // IPv6 域名列表 + enableIpv4: boolean; + enableIpv6: boolean; + ipv4GetMethod: 'api' | 'interface'; + ipv6GetMethod: 'api' | 'interface'; + ipv4GetValue?: string; + ipv6GetValue?: string; + createdAt: number; + updatedAt: number; +} + +export interface DDNSRecord { + id: string; + configId: string; + agentId: string; + domain: string; + recordType: 'A' | 'AAAA'; + oldIp?: string; + newIp: string; + status: 'success' | 'failed'; + errorMessage?: string; + createdAt: number; +} + +export interface CreateDDNSConfigRequest { + agentId: string; + name: string; + provider: string; + domainsIpv4: string[]; // IPv4 域名列表 + domainsIpv6: string[]; // IPv6 域名列表 + enableIpv4: boolean; + enableIpv6: boolean; + ipv4GetMethod?: string; + ipv6GetMethod?: string; + ipv4GetValue?: string; + ipv6GetValue?: string; +} + +export interface UpdateDDNSConfigRequest { + name?: string; + provider?: string; + domainsIpv4?: string[]; // IPv4 域名列表 + domainsIpv6?: string[]; // IPv6 域名列表 + enableIpv4?: boolean; + enableIpv6?: boolean; + ipv4GetMethod?: string; + ipv6GetMethod?: string; + ipv4GetValue?: string; + ipv6GetValue?: string; +} + +export interface UpsertDNSProviderRequest { + provider: 'aliyun' | 'tencentcloud' | 'cloudflare' | 'huaweicloud'; + enabled: boolean; + config: Record; +} diff --git a/web/src/types/index.ts b/web/src/types/index.ts index 4363731..5c2dabc 100644 --- a/web/src/types/index.ts +++ b/web/src/types/index.ts @@ -456,3 +456,12 @@ export interface AlertRecord { createdAt: number; updatedAt: number; } + +// 通用分页响应 +export interface PageResponse { + items: T[]; + total: number; +} + +// 导出 DDNS 相关类型 +export * from './ddns';