增加 DDNS 功能

This commit is contained in:
dushixiang committed 2025-12-04 22:48:39 +08:00
1 parent 6cd058dc37
commit ef94b7bf7c
40 files changed
+3257 -90

No files matched your search

+6 -1
View File
@@ -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
+13
View File
@@ -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=
+27
View File
@@ -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{},
+84
View File
@@ -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)
}
+123
View File
@@ -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
}
+49
View File
@@ -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
}
+12 -1
View File
@@ -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
+295
View File
@@ -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),
})
}
+183
View File
@@ -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
}
+49
View File
@@ -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"
}
+13
View File
@@ -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
+20
View File
@@ -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 地址
}
+3
View File
@@ -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
+64
View File
@@ -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
}
+65
View File
@@ -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
}
+423
View File
@@ -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)
}
+81
View File
@@ -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{}, // 默认为空数组
},
}
// 遍历并初始化每个配置
+15 -7
View File
@@ -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
}
+34 -23
View File
@@ -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
}
+3 -4
View File
@@ -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
}
-7
View File
@@ -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
}
+227
View File
@@ -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
}
+61
View File
@@ -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)
}
+95
View File
@@ -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)
}
}
+61
View File
@@ -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<DDNSConfigListResponse>(`/admin/ddns?${params.toString()}`);
};
// 创建 DDNS 配置
export const createDDNSConfig = (data: CreateDDNSConfigRequest) => {
return post<DDNSConfig>('/admin/ddns', data);
};
// 获取 DDNS 配置详情
export const getDDNSConfig = (id: string) => {
return get<DDNSConfig>(`/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<DDNSRecordListResponse>(`/admin/ddns/${id}/records`);
};
+17
View File
@@ -0,0 +1,17 @@
import {del, get, post} from './request';
import type {DNSProviderConfig, UpsertDNSProviderRequest} from '@/types/ddns';
// 获取所有 DNS Provider 配置
export const getDNSProviders = () => {
return get<DNSProviderConfig[]>('/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}`);
};
+2
View File
@@ -8,6 +8,7 @@ export interface Action {
icon?: React.ReactElement<LucideIcon>;
type?: 'default' | 'primary';
onClick: () => void;
danger?: boolean;
}
interface PageHeaderProps {
@@ -34,6 +35,7 @@ export const PageHeader: React.FC<PageHeaderProps> = ({title, description, actio
type={action.type || 'default'}
icon={action.icon}
onClick={action.onClick}
danger={action.danger}
>
{action.label}
</Button>
+7 -1
View File
@@ -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: <Activity className="h-4 w-4" strokeWidth={2}/>,
},
{
key: 'ddns',
label: 'DDNS',
path: '/admin/ddns',
icon: <Globe className="h-4 w-4" strokeWidth={2}/>,
},
{
key: 'alert-records',
label: '告警记录',
+5 -5
View File
@@ -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 key={index} color="blue" bordered={false} style={{ marginBottom: 4 }}>
<Tag key={index} color="blue" bordered={false} style={{marginBottom: 4}}>
{tag}
</Tag>
))
@@ -374,7 +374,7 @@ const AgentList = () => {
<Select
mode="tags"
placeholder="请选择或输入标签"
options={existingTags?.map(tag => ({ label: tag, value: tag }))}
options={existingTags?.map(tag => ({label: tag, value: tag}))}
tokenSeparators={[',']}
/>
</Form.Item>
+14 -15
View File
@@ -1,8 +1,8 @@
import {useRef, useState} from 'react';
import type {ActionType, ProColumns} from '@ant-design/pro-components';
import {ProTable} from '@ant-design/pro-components';
import {App, Button, Select, Space, Tag} from 'antd';
import {RefreshCw, Trash2} from 'lucide-react';
import {App, Divider, Select, Space, Tag} from 'antd';
import {Trash2} from 'lucide-react';
import {clearAlertRecords, getAlertRecords} from '@/api/alert.ts';
import type {AlertRecord} from '@/types';
import dayjs from 'dayjs';
@@ -184,8 +184,20 @@ const AlertRecordList = () => {
<PageHeader
title="告警记录"
description="查看和管理系统的告警记录"
actions={[
{
key: 'clear',
label: '清空记录',
icon: <Trash2 className="h-4 w-4"/>,
type: 'primary',
danger: true,
onClick: handleClear,
},
]}
/>
<Divider/>
<ProTable<AlertRecord>
columns={columns}
actionRef={actionRef}
@@ -235,19 +247,6 @@ const AlertRecordList = () => {
}
options={agentOptions}
/>
<Button
type="primary"
icon={<RefreshCw className="h-4 w-4"/>}
onClick={() => actionRef.current?.reload()}
>
刷新
</Button>
<Button danger
icon={<Trash2 className="h-4 w-4"/>}
onClick={handleClear}
>
清空记录
</Button>
</Space>,
],
}}
+6 -6
View File
@@ -243,12 +243,6 @@ const ApiKeyList = () => {
title="API密钥管理"
description="管理探针连接所需的API密钥,用于验证探针注册"
actions={[
{
key: 'refresh',
label: '刷新',
icon: <RefreshCw size={16}/>,
onClick: () => actionRef.current?.reload(),
},
{
key: 'create',
label: '生成密钥',
@@ -256,6 +250,12 @@ const ApiKeyList = () => {
type: 'primary',
onClick: handleCreate,
},
{
key: 'refresh',
label: '刷新',
icon: <RefreshCw size={16}/>,
onClick: () => actionRef.current?.reload(),
},
]}
/>
+341
View File
@@ -0,0 +1,341 @@
import {useEffect, useState} from 'react';
import {Alert, App, Form, Input, Modal, Select, Switch} from 'antd';
import {createDDNSConfig, updateDDNSConfig} from '@/api/ddns';
import {getDNSProviders} from '@/api/dnsProvider';
import type {CreateDDNSConfigRequest, UpdateDDNSConfigRequest, DDNSConfig, DNSProviderConfig} from '@/types/ddns';
import {getAgents} from '@/api/agent';
import type {Agent} from '@/types';
interface DDNSModalProps {
open: boolean;
id?: string; // 如果有 id 则为编辑模式,否则为新建模式
config?: DDNSConfig; // 编辑模式时传入的配置数据
onCancel: () => void;
onSuccess: () => void;
}
const DDNSModal = ({open, id, config, onCancel, onSuccess}: DDNSModalProps) => {
const {message: messageApi} = App.useApp();
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const [agents, setAgents] = useState<Agent[]>([]);
const [providers, setProviders] = useState<DNSProviderConfig[]>([]);
const isEditMode = !!id;
// 默认 IPv4 API 列表
const defaultIPv4APIs = [
'https://myip.ipip.net',
'https://ddns.oray.com/checkip',
'https://ip.3322.net',
'https://4.ipw.cn',
'https://v4.yinghualuo.cn/bejson',
];
// 默认 IPv6 API 列表
const defaultIPv6APIs = [
'https://speed.neu6.edu.cn/getIP.php',
'https://v6.ident.me',
'https://6.ipw.cn',
'https://v6.yinghualuo.cn/bejson',
];
useEffect(() => {
if (open) {
loadProviders();
// 新建模式才需要加载探针列表
if (!isEditMode) {
loadAgents();
}
}
}, [open, isEditMode]);
useEffect(() => {
if (open && isEditMode && config) {
// 编辑模式:填充表单数据
form.setFieldsValue({
name: config.name,
provider: config.provider,
enableIpv4: config.enableIpv4,
enableIpv6: config.enableIpv6,
ipv4GetMethod: config.ipv4GetMethod,
ipv6GetMethod: config.ipv6GetMethod,
ipv4GetValue: config.ipv4GetValue,
ipv6GetValue: config.ipv6GetValue,
domainsIpv4: (config.domainsIpv4 || []).join('\n'),
domainsIpv6: (config.domainsIpv6 || []).join('\n'),
});
} else if (open && !isEditMode) {
// 新建模式:重置表单为默认值
form.resetFields();
}
}, [open, isEditMode, config, form]);
const loadAgents = async () => {
try {
const data = await getAgents();
setAgents(data.items || []);
} catch (error) {
messageApi.error('加载探针列表失败');
}
};
const loadProviders = async () => {
try {
const response = await getDNSProviders();
// 只显示已启用的 provider
const enabledProviders = (response.data || []).filter(p => p.enabled);
setProviders(enabledProviders);
} catch (error) {
messageApi.error('加载 DNS Provider 列表失败');
}
};
const handleOk = async () => {
try {
const values = await form.validateFields();
// 处理 IPv4 域名列表
const domainsIpv4Text = values.domainsIpv4 || '';
const domainsIpv4 = domainsIpv4Text
.split('\n')
.map((line: string) => line.trim())
.filter((line: string) => line.length > 0);
// 处理 IPv6 域名列表
const domainsIpv6Text = values.domainsIpv6 || '';
const domainsIpv6 = domainsIpv6Text
.split('\n')
.map((line: string) => line.trim())
.filter((line: string) => line.length > 0);
const data = {
...values,
domainsIpv4,
domainsIpv6,
};
setLoading(true);
if (isEditMode && id) {
// 编辑模式
await updateDDNSConfig(id, data as UpdateDDNSConfigRequest);
messageApi.success('更新成功');
} else {
// 新建模式
await createDDNSConfig(data as CreateDDNSConfigRequest);
messageApi.success('创建成功');
}
form.resetFields();
onSuccess();
} catch (error: any) {
if (error.errorFields) {
return;
}
messageApi.error(error.message || (isEditMode ? '更新失败' : '创建失败'));
} finally {
setLoading(false);
}
};
const handleCancel = () => {
form.resetFields();
onCancel();
};
const enableIpv4 = Form.useWatch('enableIpv4', form);
const enableIpv6 = Form.useWatch('enableIpv6', form);
const providerNames: Record<string, string> = {
aliyun: '阿里云',
tencentcloud: '腾讯云',
cloudflare: 'Cloudflare',
huaweicloud: '华为云',
};
return (
<Modal
title={isEditMode ? '编辑 DDNS 配置' : '新建 DDNS 配置'}
open={open}
onOk={handleOk}
onCancel={handleCancel}
confirmLoading={loading}
width={700}
destroyOnHidden
>
<Form
form={form}
layout="vertical"
initialValues={{
enableIpv4: true,
enableIpv6: false,
ipv4GetMethod: 'api',
ipv6GetMethod: 'api',
}}
>
<Form.Item label="配置名称" name="name" rules={[{required: true, message: '请输入配置名称'}]}>
<Input placeholder="例如:生产环境 DDNS"/>
</Form.Item>
{/* 新建模式才显示探针选择器 */}
{!isEditMode && (
<Form.Item label="探针" name="agentId" rules={[{required: true, message: '请选择探针'}]}>
<Select
showSearch
placeholder="选择探针"
optionFilterProp="children"
filterOption={(input, option) =>
(option?.label ?? '').toLowerCase().includes(input.toLowerCase())
}
options={agents.map((agent) => ({
label: agent.name || agent.id,
value: agent.id,
}))}
/>
</Form.Item>
)}
<Form.Item label="DNS 服务商" name="provider" rules={[{required: true, message: '请选择 DNS 服务商'}]}>
<Select placeholder="选择已配置的 DNS 服务商" disabled={providers.length === 0}>
{providers.map((p) => (
<Select.Option key={p.provider} value={p.provider}>
{providerNames[p.provider]}
</Select.Option>
))}
</Select>
</Form.Item>
<div className={'space-y-4'}>
{/* IPv4 配置卡片 */}
<div className="rounded-lg border p-4">
<div className="mb-3 flex items-center justify-between">
<h4 className="font-medium">IPv4 配置</h4>
<Form.Item name="enableIpv4" valuePropName="checked" noStyle>
<Switch/>
</Form.Item>
</div>
{enableIpv4 && (
<>
<Form.Item
label="域名列表"
name="domainsIpv4"
rules={[
{required: true, message: '请输入至少一个 IPv4 域名'},
{
validator: (_, value) => {
if (!value || value.trim() === '') {
return Promise.reject('请输入至少一个 IPv4 域名');
}
return Promise.resolve();
}
}
]}
extra="每行输入一个域名,用于 IPv4(A 记录)"
>
<Input.TextArea
rows={3}
placeholder="每行输入一个域名,例如:&#10;ddns.example.com&#10;www.example.com"
/>
</Form.Item>
<Form.Item
label="获取方式"
name="ipv4GetMethod"
rules={[{required: true, message: '请选择 IPv4 获取方式'}]}
>
<Select>
<Select.Option value="api">API 获取</Select.Option>
<Select.Option value="interface">网络接口</Select.Option>
</Select>
</Form.Item>
<Form.Item
label="配置值"
name="ipv4GetValue"
extra="留空使用默认 API,或指定网络接口名称(如: eth0)"
tooltip={
<div className="space-y-1">
<div className="font-medium">默认 IPv4 API 列表:</div>
{defaultIPv4APIs.map((api, index) => (
<div key={index} className="text-xs">{api}</div>
))}
</div>
}
>
<Input placeholder="留空使用默认 API / 接口名: eth0"/>
</Form.Item>
</>
)}
</div>
{/* IPv6 配置卡片 */}
<div className="rounded-lg border p-4">
<div className="mb-3 flex items-center justify-between">
<h4 className="font-medium">IPv6 配置</h4>
<Form.Item name="enableIpv6" valuePropName="checked" noStyle>
<Switch/>
</Form.Item>
</div>
{enableIpv6 && (
<>
<Form.Item
label="域名列表"
name="domainsIpv6"
rules={[
{required: true, message: '请输入至少一个 IPv6 域名'},
{
validator: (_, value) => {
if (!value || value.trim() === '') {
return Promise.reject('请输入至少一个 IPv6 域名');
}
return Promise.resolve();
}
}
]}
extra="每行输入一个域名,用于 IPv6(AAAA 记录)"
>
<Input.TextArea
rows={3}
placeholder="每行输入一个域名,例如:&#10;ddns-v6.example.com&#10;www-v6.example.com"
/>
</Form.Item>
<Form.Item
label="获取方式"
name="ipv6GetMethod"
rules={[{required: true, message: '请选择 IPv6 获取方式'}]}
>
<Select>
<Select.Option value="api">API 获取</Select.Option>
<Select.Option value="interface">网络接口</Select.Option>
</Select>
</Form.Item>
<Form.Item
label="配置值"
name="ipv6GetValue"
extra="留空使用默认 API,或指定网络接口名称(如: eth0)"
tooltip={
<div className="space-y-1">
<div className="font-medium">默认 IPv6 API 列表:</div>
{defaultIPv6APIs.map((api, index) => (
<div key={index} className="text-xs">{api}</div>
))}
</div>
}
>
<Input placeholder="留空使用默认 API / 接口名: eth0"/>
</Form.Item>
</>
)}
</div>
</div>
</Form>
</Modal>
);
};
export default DDNSModal;
@@ -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<DNSProviderConfig[]>([]);
const [activeTab, setActiveTab] = useState<string>('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<string, string> = {};
// 根据不同的 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<string, string> = {
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 (
<div>
<Descriptions bordered size="small" column={1}>
<Descriptions.Item label="状态">
<Tag color={providerConfig.enabled ? 'green' : 'default'}>
{providerConfig.enabled ? '已启用' : '已禁用'}
</Tag>
</Descriptions.Item>
{Object.entries(providerConfig.config).map(([key, value]) => (
<Descriptions.Item key={key} label={key}>
<code className="text-gray-600">{value}</code>
</Descriptions.Item>
))}
</Descriptions>
<div className="mt-4">
<Button danger onClick={() => handleDelete(providerType)}>
删除配置
</Button>
</div>
</div>
);
}
// 未配置时显示表单
return (
<Form form={form} layout="vertical">
<Form.Item
label="启用"
name={`${providerType}_enabled`}
valuePropName="checked"
>
<Switch />
</Form.Item>
{providerType === 'aliyun' && (
<>
<Form.Item
label="AccessKey ID"
name={`${providerType}_accessKeyId`}
rules={[{required: true, message: '请输入 AccessKey ID'}]}
>
<Input placeholder="输入阿里云 AccessKey ID" />
</Form.Item>
<Form.Item
label="AccessKey Secret"
name={`${providerType}_accessKeySecret`}
rules={[{required: true, message: '请输入 AccessKey Secret'}]}
>
<Input.Password placeholder="输入阿里云 AccessKey Secret" />
</Form.Item>
</>
)}
{providerType === 'tencentcloud' && (
<>
<Form.Item
label="Secret ID"
name={`${providerType}_secretId`}
rules={[{required: true, message: '请输入 Secret ID'}]}
>
<Input placeholder="输入腾讯云 Secret ID" />
</Form.Item>
<Form.Item
label="Secret Key"
name={`${providerType}_secretKey`}
rules={[{required: true, message: '请输入 Secret Key'}]}
>
<Input.Password placeholder="输入腾讯云 Secret Key" />
</Form.Item>
</>
)}
{providerType === 'cloudflare' && (
<Form.Item
label="API Token"
name={`${providerType}_apiToken`}
rules={[{required: true, message: '请输入 API Token'}]}
>
<Input.Password placeholder="输入 Cloudflare API Token" />
</Form.Item>
)}
{providerType === 'huaweicloud' && (
<>
<Form.Item
label="AccessKey ID"
name={`${providerType}_accessKeyId`}
rules={[{required: true, message: '请输入 AccessKey ID'}]}
>
<Input placeholder="输入华为云 AccessKey ID" />
</Form.Item>
<Form.Item
label="Secret Access Key"
name={`${providerType}_secretAccessKey`}
rules={[{required: true, message: '请输入 Secret Access Key'}]}
>
<Input.Password placeholder="输入华为云 Secret Access Key" />
</Form.Item>
<Form.Item
label="区域"
name={`${providerType}_region`}
>
<Input placeholder="例如:cn-south-1(可选,默认 cn-south-1)" />
</Form.Item>
</>
)}
<Button type="primary" loading={loading} onClick={() => handleSave(providerType)}>
保存
</Button>
</Form>
);
};
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 (
<Modal
title="DNS Provider 配置管理"
open={open}
onCancel={onCancel}
footer={null}
width={700}
destroyOnClose
>
<Tabs activeKey={activeTab} onChange={setActiveTab} items={tabItems} />
</Modal>
);
};
export default DNSProviderModal;
@@ -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<DDNSRecord[]>([]);
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<DDNSRecord>[] = [
{
title: '域名',
dataIndex: 'domain',
key: 'domain',
width: 200,
},
{
title: '记录类型',
dataIndex: 'recordType',
key: 'recordType',
width: 100,
render: (type: string) => (
<Tag color={type === 'A' ? 'green' : 'cyan'}>{type}</Tag>
),
},
{
title: 'IP 变化',
key: 'ipChange',
width: 300,
render: (_, record) => (
<div className="flex items-center gap-2">
<span className="font-mono text-gray-600">{record.oldIp || '(初始)'}</span>
<ArrowRight className="h-4 w-4 text-gray-400"/>
<span className="font-mono font-medium text-blue-600">{record.newIp}</span>
</div>
),
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 100,
render: (status: string) => (
<div className="flex items-center gap-1">
{status === 'success' ? (
<>
<CheckCircle2 className="h-4 w-4 text-green-500"/>
<span className="text-green-600">成功</span>
</>
) : (
<>
<XCircle className="h-4 w-4 text-red-500"/>
<span className="text-red-600">失败</span>
</>
)}
</div>
),
},
{
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 && <span className="text-red-500">{msg}</span>,
},
];
return (
<Drawer
title={`DDNS 更新记录 - ${config.name}`}
open={open}
onClose={onClose}
width={1200}
destroyOnHidden={true}
>
<div className="mb-4 rounded-lg bg-gray-50 p-4">
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-gray-500">配置名称:</span>
<span className="font-medium">{config.name}</span>
</div>
<div>
<span className="text-gray-500">DNS 服务商:</span>
<span className="font-medium">{config.provider}</span>
</div>
<div>
<span className="text-gray-500">域名数量:</span>
<span className="font-medium">{(config.domainsIpv4?.length || 0) + (config.domainsIpv6?.length || 0)} 个</span>
</div>
<div>
<span className="text-gray-500">状态:</span>
<Tag color={config.enabled ? 'success' : 'default'}>
{config.enabled ? '启用' : '禁用'}
</Tag>
</div>
</div>
</div>
{records.length === 0 ? (
<Empty description="暂无更新记录" image={Empty.PRESENTED_IMAGE_SIMPLE}/>
) : (
<Table
columns={columns}
dataSource={records}
rowKey="id"
loading={loading}
pagination={{
pageSize: 10,
showTotal: (total) => `共 ${total} 条记录`,
}}
scroll={{x: 900}}
/>
)}
</Drawer>
);
};
export default RecordsDrawer;
+301
View File
@@ -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<ActionType>(null);
const [modalOpen, setModalOpen] = useState(false);
const [recordsDrawerOpen, setRecordsDrawerOpen] = useState(false);
const [providerModalOpen, setProviderModalOpen] = useState(false);
const [selectedConfig, setSelectedConfig] = useState<DDNSConfig | null>(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<string, string> = {
aliyun: '阿里云',
tencentcloud: '腾讯云',
cloudflare: 'Cloudflare',
huaweicloud: '华为云',
};
const columns: ProColumns<DDNSConfig>[] = [
{
title: '配置名称',
dataIndex: 'name',
render: (_, record) => (
<div className="flex items-center gap-2">
<Globe className="h-4 w-4 text-blue-500" />
<span className="font-medium text-gray-900">{record.name}</span>
</div>
),
},
{
title: 'DNS 服务商',
dataIndex: 'provider',
width: 120,
render: (provider: string) => (
<Tag color="blue">{providerNames[provider] || provider}</Tag>
),
},
{
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 (
<div className="flex flex-wrap gap-1">
{allDomains.slice(0, 2).map((item, index) => (
<Tag key={index} color={item.type === 'IPv4' ? 'blue' : 'cyan'}>
{item.domain}
</Tag>
))}
{allDomains.length > 2 && (
<Tooltip title={allDomains.slice(2).map(item => `${item.domain} (${item.type})`).join(', ')}>
<Tag>+{allDomains.length - 2}</Tag>
</Tooltip>
)}
</div>
);
},
},
{
title: 'IP 配置',
key: 'ipConfig',
width: 150,
render: (_, record) => (
<Space size={4}>
{record.enableIpv4 && <Tag color="green">IPv4</Tag>}
{record.enableIpv6 && <Tag color="cyan">IPv6</Tag>}
</Space>
),
},
{
title: '状态',
dataIndex: 'enabled',
width: 80,
render: (enabled: boolean) => (
<Tag color={enabled ? 'green' : 'red'}>
{enabled ? '启用' : '禁用'}
</Tag>
),
},
{
title: '更新时间',
dataIndex: 'updatedAt',
width: 180,
render: (timestamp: number) =>
dayjs(timestamp).format('YYYY-MM-DD HH:mm:ss'),
},
{
title: '操作',
valueType: 'option',
width: 220,
render: (_, record) => [
<Button
key="records"
type="link"
size="small"
onClick={() => handleViewRecords(record)}
>
记录
</Button>,
<Button
key="toggle"
type="link"
size="small"
onClick={() => handleToggleStatus(record)}
>
{record.enabled ? '禁用' : '启用'}
</Button>,
<Button
key="edit"
type="link"
size="small"
onClick={() => handleUpdate(record)}
>
编辑
</Button>,
<Button
key="delete"
type="link"
size="small"
danger
onClick={() => handleDelete(record)}
>
删除
</Button>,
],
},
];
return (
<div className="space-y-6">
<PageHeader
title="DDNS 配置管理"
description="管理动态 DNS 配置,支持阿里云、腾讯云、Cloudflare、华为云等服务商,自动更新域名解析记录"
actions={[
{
key: 'provider',
label: 'DNS Provider',
icon: <Settings size={16} />,
type: 'primary',
onClick: () => setProviderModalOpen(true),
},
]}
/>
<Divider />
<ProTable<DDNSConfig>
columns={columns}
rowKey="id"
actionRef={actionRef}
search={false}
params={{keyword}}
pagination={{
defaultPageSize: 10,
showSizeChanger: true,
}}
toolBarRender={() => [
<Input.Search
key="search"
placeholder="按配置名称搜索"
allowClear
onSearch={(value) => {
setKeyword(value.trim());
actionRef.current?.reload();
}}
style={{width: 260}}
/>,
<Button key="add" type="primary" icon={<Plus size={16} />} onClick={handleCreate}>
新建配置
</Button>,
]}
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,
};
}
}}
/>
<DDNSModal
open={modalOpen}
id={selectedConfig?.id}
config={selectedConfig || undefined}
onCancel={() => {
setModalOpen(false);
setSelectedConfig(null);
}}
onSuccess={() => {
setModalOpen(false);
setSelectedConfig(null);
actionRef.current?.reload();
}}
/>
{selectedConfig && (
<RecordsDrawer
open={recordsDrawerOpen}
config={selectedConfig}
onClose={() => {
setRecordsDrawerOpen(false);
setSelectedConfig(null);
}}
/>
)}
<DNSProviderModal
open={providerModalOpen}
onCancel={() => setProviderModalOpen(false)}
onSuccess={() => {
// Provider 配置更新后,可能需要刷新 DDNS 列表
actionRef.current?.reload();
}}
/>
</div>
);
};
export default DDNSPage;
+6 -9
View File
@@ -333,12 +333,6 @@ const MonitorList = () => {
title="服务监控"
description="配置 HTTP/TCP/ICMP 服务可用性检测,集中管理监控策略与探针覆盖范围"
actions={[
{
key: 'refresh',
label: '刷新',
icon: <RefreshCw size={16}/>,
onClick: () => actionRef.current?.reload(),
},
{
key: 'create',
label: '新建监控',
@@ -346,6 +340,12 @@ const MonitorList = () => {
type: 'primary',
onClick: handleCreate,
},
{
key: 'refresh',
label: '刷新',
icon: <RefreshCw size={16}/>,
onClick: () => actionRef.current?.reload(),
},
]}
/>
@@ -372,9 +372,6 @@ const MonitorList = () => {
}}
style={{width: 260}}
/>,
<Button key="add" type="primary" icon={<Plus size={16}/>} onClick={handleCreate}>
新建监控
</Button>,
]}
request={async (params) => {
const {current = 1, pageSize = 10, keyword: kw = ''} = params;
-11
View File
@@ -219,17 +219,6 @@ const SystemConfigComponent = () => {
{uploading ? '处理中...' : '上传 Logo'}
</Button>
</Upload>
<div className="text-sm text-gray-500 space-y-1">
<div>💡 使用提示:</div>
<ul className="list-disc list-inside space-y-1 ml-2">
<li>支持 PNG、JPG、GIF、SVG 等常见图片格式</li>
<li>建议使用 PNG 格式,支持透明背景</li>
<li>建议图片尺寸为 256x256 像素</li>
<li>文件大小不能超过 500KB</li>
<li>上传后图片将转换为 base64 格式存储</li>
</ul>
</div>
</Space>
</Form.Item>
</Card>
+5
View File
@@ -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),
+70
View File
@@ -0,0 +1,70 @@
// DNS Provider 配置
export interface DNSProviderConfig {
provider: 'aliyun' | 'tencentcloud' | 'cloudflare' | 'huaweicloud';
enabled: boolean;
config: Record<string, string>; // 已脱敏的配置
}
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<string, string>;
}
+9
View File
@@ -456,3 +456,12 @@ export interface AlertRecord {
createdAt: number;
updatedAt: number;
}
// 通用分页响应
export interface PageResponse<T> {
items: T[];
total: number;
}
// 导出 DDNS 相关类型
export * from './ddns';