增加 DDNS 功能
This commit is contained in:
1 parent
6cd058dc37
commit
ef94b7bf7c
40 files changed
+3257
-90
No files matched your search
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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{},
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 地址
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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`);
|
||||
};
|
||||
@@ -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}`);
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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: '告警记录',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>,
|
||||
],
|
||||
}}
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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="每行输入一个域名,例如: ddns.example.com 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="每行输入一个域名,例如: ddns-v6.example.com 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -456,3 +456,12 @@ export interface AlertRecord {
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
// 通用分页响应
|
||||
export interface PageResponse<T> {
|
||||
items: T[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
// 导出 DDNS 相关类型
|
||||
export * from './ddns';
|
||||
Reference in new issue
Block a user