diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2a502e0..986d05a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,7 +21,7 @@ jobs: name: Build artifacts runs-on: ubuntu-latest container: - image: goreleaser/goreleaser-cross:v1.21 + image: goreleaser/goreleaser-cross:v1.23 env: GOOS: ${{ matrix.goos }} GOARCH: ${{ matrix.goarch }} @@ -43,7 +43,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: "1.21.x" + go-version: "1.23.x" - name: Build uses: goreleaser/goreleaser-action@v6 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b6f805f..dc91970 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -29,7 +29,7 @@ jobs: - uses: actions/setup-go@v5 with: - go-version: "1.21.x" + go-version: "1.23.x" - name: Unit test run: | diff --git a/cmd/dashboard/controller/member_api.go b/cmd/dashboard/controller/member_api.go index fde9866..1b3faa4 100644 --- a/cmd/dashboard/controller/member_api.go +++ b/cmd/dashboard/controller/member_api.go @@ -12,6 +12,7 @@ import ( "github.com/gin-gonic/gin" "github.com/jinzhu/copier" + "golang.org/x/net/idna" "gorm.io/gorm" "github.com/naiba/nezha/model" @@ -38,6 +39,7 @@ func (ma *memberAPI) serve() { mr.GET("/search-server", ma.searchServer) mr.GET("/search-tasks", ma.searchTask) + mr.GET("/search-ddns", ma.searchDDNS) mr.POST("/server", ma.addOrEditServer) mr.POST("/monitor", ma.addOrEditMonitor) mr.POST("/cron", ma.addOrEditCron) @@ -46,6 +48,7 @@ func (ma *memberAPI) serve() { mr.POST("/batch-update-server-group", ma.batchUpdateServerGroup) mr.POST("/batch-delete-server", ma.batchDeleteServer) mr.POST("/notification", ma.addOrEditNotification) + mr.POST("/ddns", ma.addOrEditDDNS) mr.POST("/nat", ma.addOrEditNAT) mr.POST("/alert-rule", ma.addOrEditAlertRule) mr.POST("/setting", ma.updateSetting) @@ -211,6 +214,11 @@ func (ma *memberAPI) delete(c *gin.Context) { if err == nil { singleton.OnDeleteNotification(id) } + case "ddns": + err = singleton.DB.Unscoped().Delete(&model.DDNSProfile{}, "id = ?", id).Error + if err == nil { + singleton.OnDDNSUpdate() + } case "nat": err = singleton.DB.Unscoped().Delete(&model.NAT{}, "id = ?", id).Error if err == nil { @@ -299,20 +307,38 @@ func (ma *memberAPI) searchTask(c *gin.Context) { }) } +func (ma *memberAPI) searchDDNS(c *gin.Context) { + var ddns []model.DDNSProfile + likeWord := "%" + c.Query("word") + "%" + singleton.DB.Select("id,name").Where("id = ? OR name LIKE ?", + c.Query("word"), likeWord).Find(&ddns) + + var resp []searchResult + for i := 0; i < len(ddns); i++ { + resp = append(resp, searchResult{ + Value: ddns[i].ID, + Name: ddns[i].Name, + Text: ddns[i].Name, + }) + } + + c.JSON(http.StatusOK, map[string]interface{}{ + "success": true, + "results": resp, + }) +} + type serverForm struct { - ID uint64 - Name string `binding:"required"` - DisplayIndex int - Secret string - Tag string - Note string - PublicNote string - HideForGuest string - EnableDDNS string - EnableIPv4 string - EnableIpv6 string - DDNSDomain string - DDNSProfile string + ID uint64 + Name string `binding:"required"` + DisplayIndex int + Secret string + Tag string + Note string + PublicNote string + HideForGuest string + EnableDDNS string + DDNSProfilesRaw string } func (ma *memberAPI) addOrEditServer(c *gin.Context) { @@ -330,18 +356,18 @@ func (ma *memberAPI) addOrEditServer(c *gin.Context) { s.PublicNote = sf.PublicNote s.HideForGuest = sf.HideForGuest == "on" s.EnableDDNS = sf.EnableDDNS == "on" - s.EnableIPv4 = sf.EnableIPv4 == "on" - s.EnableIpv6 = sf.EnableIpv6 == "on" - s.DDNSDomain = sf.DDNSDomain - s.DDNSProfile = sf.DDNSProfile - if s.ID == 0 { - s.Secret, err = utils.GenerateRandomString(18) - if err == nil { - err = singleton.DB.Create(&s).Error + s.DDNSProfilesRaw = sf.DDNSProfilesRaw + err = utils.Json.Unmarshal([]byte(sf.DDNSProfilesRaw), &s.DDNSProfiles) + if err == nil { + if s.ID == 0 { + s.Secret, err = utils.GenerateRandomString(18) + if err == nil { + err = singleton.DB.Create(&s).Error + } + } else { + isEdit = true + err = singleton.DB.Save(&s).Error } - } else { - isEdit = true - err = singleton.DB.Save(&s).Error } } if err != nil { @@ -743,6 +769,79 @@ func (ma *memberAPI) addOrEditNotification(c *gin.Context) { }) } +type ddnsForm struct { + ID uint64 + MaxRetries uint64 + EnableIPv4 string + EnableIPv6 string + Name string + Provider uint8 + DomainsRaw string + AccessID string + AccessSecret string + WebhookURL string + WebhookMethod uint8 + WebhookRequestBody string + WebhookHeaders string +} + +func (ma *memberAPI) addOrEditDDNS(c *gin.Context) { + var df ddnsForm + var p model.DDNSProfile + err := c.ShouldBindJSON(&df) + if err == nil { + if df.MaxRetries < 1 || df.MaxRetries > 10 { + err = errors.New("重试次数必须为大于 1 且不超过 10 的整数") + } + } + if err == nil { + p.Name = df.Name + p.ID = df.ID + enableIPv4 := df.EnableIPv4 == "on" + enableIPv6 := df.EnableIPv6 == "on" + p.EnableIPv4 = &enableIPv4 + p.EnableIPv6 = &enableIPv6 + p.MaxRetries = df.MaxRetries + p.Provider = df.Provider + p.DomainsRaw = df.DomainsRaw + p.Domains = strings.Split(p.DomainsRaw, ",") + p.AccessID = df.AccessID + p.AccessSecret = df.AccessSecret + p.WebhookURL = df.WebhookURL + p.WebhookMethod = df.WebhookMethod + p.WebhookRequestBody = df.WebhookRequestBody + p.WebhookHeaders = df.WebhookHeaders + + for n, domain := range p.Domains { + // IDN to ASCII + domainValid, domainErr := idna.Lookup.ToASCII(domain) + if domainErr != nil { + err = fmt.Errorf("域名 %s 解析错误: %v", domain, domainErr) + break + } + p.Domains[n] = domainValid + } + } + if err == nil { + if p.ID == 0 { + err = singleton.DB.Create(&p).Error + } else { + err = singleton.DB.Save(&p).Error + } + } + if err != nil { + c.JSON(http.StatusOK, model.Response{ + Code: http.StatusBadRequest, + Message: fmt.Sprintf("请求错误:%s", err), + }) + return + } + singleton.OnDDNSUpdate() + c.JSON(http.StatusOK, model.Response{ + Code: http.StatusOK, + }) +} + type natForm struct { ID uint64 Name string diff --git a/cmd/dashboard/controller/member_page.go b/cmd/dashboard/controller/member_page.go index 1982144..c464011 100644 --- a/cmd/dashboard/controller/member_page.go +++ b/cmd/dashboard/controller/member_page.go @@ -27,6 +27,7 @@ func (mp *memberPage) serve() { mr.GET("/monitor", mp.monitor) mr.GET("/cron", mp.cron) mr.GET("/notification", mp.notification) + mr.GET("/ddns", mp.ddns) mr.GET("/nat", mp.nat) mr.GET("/setting", mp.setting) mr.GET("/api", mp.api) @@ -78,6 +79,17 @@ func (mp *memberPage) notification(c *gin.Context) { })) } +func (mp *memberPage) ddns(c *gin.Context) { + var data []model.DDNSProfile + singleton.DB.Find(&data) + c.HTML(http.StatusOK, "dashboard-"+singleton.Conf.Site.DashboardTheme+"/ddns", mygin.CommonEnvironment(c, gin.H{ + "Title": singleton.Localizer.MustLocalize(&i18n.LocalizeConfig{MessageID: "DDNS"}), + "DDNS": data, + "ProviderMap": model.ProviderMap, + "ProviderList": model.ProviderList, + })) +} + func (mp *memberPage) nat(c *gin.Context) { var data []model.NAT singleton.DB.Find(&data) diff --git a/go.mod b/go.mod index beae780..4a99117 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,10 @@ require ( github.com/hashicorp/go-uuid v1.0.3 github.com/jinzhu/copier v0.4.0 github.com/json-iterator/go v1.1.12 + github.com/libdns/cloudflare v0.1.1 + github.com/libdns/libdns v0.2.2 + github.com/libdns/tencentcloud v1.0.0 + github.com/miekg/dns v1.1.62 github.com/nicksnyder/go-i18n/v2 v2.4.0 github.com/ory/graceful v0.1.3 github.com/oschwald/maxminddb-golang v1.13.1 @@ -71,6 +75,7 @@ require ( github.com/spf13/afero v1.11.0 // indirect github.com/spf13/cast v1.6.0 // indirect github.com/subosito/gotenv v1.6.0 // indirect + github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.597 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect @@ -79,8 +84,10 @@ require ( go.uber.org/multierr v1.9.0 // indirect golang.org/x/arch v0.3.0 // indirect golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect + golang.org/x/mod v0.18.0 // indirect golang.org/x/sys v0.22.0 // indirect golang.org/x/time v0.5.0 // indirect + golang.org/x/tools v0.22.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20240227224415-6ceb2ff114de // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index 03c89dd..14f23c5 100644 --- a/go.sum +++ b/go.sum @@ -107,6 +107,12 @@ github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY= github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q= github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4= +github.com/libdns/cloudflare v0.1.1 h1:FVPfWwP8zZCqj268LZjmkDleXlHPlFU9KC4OJ3yn054= +github.com/libdns/cloudflare v0.1.1/go.mod h1:9VK91idpOjg6v7/WbjkEW49bSCxj00ALesIFDhJ8PBU= +github.com/libdns/libdns v0.2.2 h1:O6ws7bAfRPaBsgAYt8MDe2HcNBGC29hkZ9MX2eUSX3s= +github.com/libdns/libdns v0.2.2/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ= +github.com/libdns/tencentcloud v1.0.0 h1:u4LXnYu/lu/9P5W+MCVPeSDnwI+6w+DxYhQ1wSnQOuU= +github.com/libdns/tencentcloud v1.0.0/go.mod h1:NlCgPumzUsZWSOo1+Q/Hfh8G6TNRAaTUeWQdg6LbtUI= github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= @@ -116,6 +122,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM= github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= +github.com/miekg/dns v1.1.62 h1:cN8OuEF1/x5Rq6Np+h1epln8OiyPWV+lROx9LxcGgIQ= +github.com/miekg/dns v1.1.62/go.mod h1:mvDlcItzm+br7MToIKqkglaGhlFMHJ9DTNNWONWXbNQ= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -180,6 +188,8 @@ github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsT github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.597 h1:C0GHdLTfikLVoEzfhgPfrZ7LwlG0xiCmk6iwNKE+xs0= +github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.597/go.mod h1:7sCQWVkxcsR38nffDW057DRGk8mUjK1Ing/EFOK8s8Y= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= @@ -209,6 +219,8 @@ golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30= golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M= golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= +golang.org/x/mod v0.18.0 h1:5+9lSbEzPSdWkH32vYPBwEpX8KwDbM52Ud9xBUvNlb0= +golang.org/x/mod v0.18.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys= @@ -238,8 +250,8 @@ golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= -golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/tools v0.22.0 h1:gqSGLZqv+AI9lIQzniJ0nZDRG5GBPsSi+DRNHWNz6yA= +golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/genproto/googleapis/rpc v0.0.0-20240227224415-6ceb2ff114de h1:cZGRis4/ot9uVm639a+rHCUaG0JJHEsdyzSQTMX+suY= google.golang.org/genproto/googleapis/rpc v0.0.0-20240227224415-6ceb2ff114de/go.mod h1:H4O17MA/PE9BsGx3w+a+W2VOLLD1Qf7oJneAoU6WktY= diff --git a/model/config.go b/model/config.go index 17fe7e3..8560922 100644 --- a/model/config.go +++ b/model/config.go @@ -125,30 +125,6 @@ type Config struct { IgnoredIPNotificationServerIDs map[uint64]bool // [ServerID] -> bool(值为true代表当前ServerID在特定服务器列表内) MaxTCPPingValue int32 AvgPingCount int - - // 动态域名解析更新 - DDNS struct { - Enable bool - Provider string - AccessID string - AccessSecret string - WebhookURL string - WebhookMethod string - WebhookRequestBody string - WebhookHeaders string - MaxRetries uint32 - Profiles map[string]DDNSProfile - } -} - -type DDNSProfile struct { - Provider string - AccessID string - AccessSecret string - WebhookURL string - WebhookMethod string - WebhookRequestBody string - WebhookHeaders string } // Read 读取配置文件并应用 @@ -189,9 +165,6 @@ func (c *Config) Read(path string) error { if c.AvgPingCount == 0 { c.AvgPingCount = 2 } - if c.DDNS.MaxRetries == 0 { - c.DDNS.MaxRetries = 3 - } if c.Oauth2.OidcScopes == "" { c.Oauth2.OidcScopes = "openid,profile,email" } diff --git a/model/ddns.go b/model/ddns.go new file mode 100644 index 0000000..3a19b64 --- /dev/null +++ b/model/ddns.go @@ -0,0 +1,98 @@ +package model + +import ( + "strings" + + "gorm.io/gorm" +) + +const ( + ProviderDummy = iota + ProviderWebHook + ProviderCloudflare + ProviderTencentCloud +) + +const ( + _Dummy = "dummy" + _WebHook = "webhook" + _Cloudflare = "cloudflare" + _TencentCloud = "tencentcloud" +) + +var ProviderMap = map[uint8]string{ + ProviderDummy: _Dummy, + ProviderWebHook: _WebHook, + ProviderCloudflare: _Cloudflare, + ProviderTencentCloud: _TencentCloud, +} + +var ProviderList = []DDNSProvider{ + { + Name: _Dummy, + ID: ProviderDummy, + }, + { + Name: _Cloudflare, + ID: ProviderCloudflare, + AccessSecret: true, + }, + { + Name: _TencentCloud, + ID: ProviderTencentCloud, + AccessID: true, + AccessSecret: true, + }, + // Least frequently used, always place this at the end + { + Name: _WebHook, + ID: ProviderWebHook, + AccessID: true, + AccessSecret: true, + WebhookURL: true, + WebhookMethod: true, + WebhookRequestBody: true, + WebhookHeaders: true, + }, +} + +type DDNSProfile struct { + Common + EnableIPv4 *bool + EnableIPv6 *bool + MaxRetries uint64 + Name string + Provider uint8 + AccessID string + AccessSecret string + WebhookURL string + WebhookMethod uint8 + WebhookRequestType uint8 + WebhookRequestBody string + WebhookHeaders string + + Domains []string `gorm:"-"` + DomainsRaw string +} + +func (d DDNSProfile) TableName() string { + return "ddns" +} + +func (d *DDNSProfile) AfterFind(tx *gorm.DB) error { + if d.DomainsRaw != "" { + d.Domains = strings.Split(d.DomainsRaw, ",") + } + return nil +} + +type DDNSProvider struct { + Name string + ID uint8 + AccessID bool + AccessSecret bool + WebhookURL bool + WebhookMethod bool + WebhookRequestBody bool + WebhookHeaders bool +} diff --git a/model/server.go b/model/server.go index d481087..e0a923b 100644 --- a/model/server.go +++ b/model/server.go @@ -3,27 +3,28 @@ package model import ( "fmt" "html/template" + "log" "sync" "time" "github.com/naiba/nezha/pkg/utils" pb "github.com/naiba/nezha/proto" + "gorm.io/gorm" ) type Server struct { Common Name string - Tag string // 分组名 - Secret string `gorm:"uniqueIndex" json:"-"` - Note string `json:"-"` // 管理员可见备注 - PublicNote string `json:"PublicNote,omitempty"` // 公开备注 - DisplayIndex int // 展示排序,越大越靠前 - HideForGuest bool // 对游客隐藏 - EnableDDNS bool `json:"-"` // 是否启用DDNS 未在配置文件中启用DDNS 或 DDNS检查时间为0时此项无效 - EnableIPv4 bool `json:"-"` // 是否启用DDNS IPv4 - EnableIpv6 bool `json:"-"` // 是否启用DDNS IPv6 - DDNSDomain string `json:"-"` // DDNS中的前缀 如基础域名为abc.oracle DDNSName为mjj 就会把mjj.abc.oracle解析服务器IP 为空则停用 - DDNSProfile string `json:"-"` // DDNS配置 + Tag string // 分组名 + Secret string `gorm:"uniqueIndex" json:"-"` + Note string `json:"-"` // 管理员可见备注 + PublicNote string `json:"PublicNote,omitempty"` // 公开备注 + DisplayIndex int // 展示排序,越大越靠前 + HideForGuest bool // 对游客隐藏 + EnableDDNS bool // 启用DDNS + DDNSProfiles []uint64 `gorm:"-" json:"-"` // DDNS配置 + + DDNSProfilesRaw string `gorm:"default:'[]';column:ddns_profiles_raw" json:"-"` Host *Host `gorm:"-"` State *HostState `gorm:"-"` @@ -48,6 +49,16 @@ func (s *Server) CopyFromRunningServer(old *Server) { s.PrevTransferOutSnapshot = old.PrevTransferOutSnapshot } +func (s *Server) AfterFind(tx *gorm.DB) error { + if s.DDNSProfilesRaw != "" { + if err := utils.Json.Unmarshal([]byte(s.DDNSProfilesRaw), &s.DDNSProfiles); err != nil { + log.Println("NEZHA>> Server.AfterFind:", err) + return nil + } + } + return nil +} + func boolToString(b bool) string { if b { return "true" @@ -60,8 +71,7 @@ func (s Server) MarshalForDashboard() template.JS { tag, _ := utils.Json.Marshal(s.Tag) note, _ := utils.Json.Marshal(s.Note) secret, _ := utils.Json.Marshal(s.Secret) - ddnsDomain, _ := utils.Json.Marshal(s.DDNSDomain) - ddnsProfile, _ := utils.Json.Marshal(s.DDNSProfile) + ddnsProfilesRaw, _ := utils.Json.Marshal(s.DDNSProfilesRaw) publicNote, _ := utils.Json.Marshal(s.PublicNote) - return template.JS(fmt.Sprintf(`{"ID":%d,"Name":%s,"Secret":%s,"DisplayIndex":%d,"Tag":%s,"Note":%s,"HideForGuest": %s,"EnableDDNS": %s,"EnableIPv4": %s,"EnableIpv6": %s,"DDNSDomain": %s,"DDNSProfile": %s,"PublicNote": %s}`, s.ID, name, secret, s.DisplayIndex, tag, note, boolToString(s.HideForGuest), boolToString(s.EnableDDNS), boolToString(s.EnableIPv4), boolToString(s.EnableIpv6), ddnsDomain, ddnsProfile, publicNote)) + return template.JS(fmt.Sprintf(`{"ID":%d,"Name":%s,"Secret":%s,"DisplayIndex":%d,"Tag":%s,"Note":%s,"HideForGuest": %s,"EnableDDNS": %s,"DDNSProfilesRaw": %s,"PublicNote": %s}`, s.ID, name, secret, s.DisplayIndex, tag, note, boolToString(s.HideForGuest), boolToString(s.EnableDDNS), ddnsProfilesRaw, publicNote)) } diff --git a/pkg/ddns/cloudflare.go b/pkg/ddns/cloudflare.go deleted file mode 100644 index ef14d9d..0000000 --- a/pkg/ddns/cloudflare.go +++ /dev/null @@ -1,190 +0,0 @@ -package ddns - -import ( - "bytes" - "errors" - "fmt" - "io" - "log" - "net/http" - "net/url" - - "github.com/naiba/nezha/pkg/utils" -) - -const baseEndpoint = "https://api.cloudflare.com/client/v4/zones" - -type ProviderCloudflare struct { - isIpv4 bool - domainConfig *DomainConfig - secret string - zoneId string - ipAddr string - recordId string - recordType string -} - -type cfReq struct { - Name string `json:"name"` - Type string `json:"type"` - Content string `json:"content"` - TTL uint32 `json:"ttl"` - Proxied bool `json:"proxied"` -} - -func NewProviderCloudflare(s string) *ProviderCloudflare { - return &ProviderCloudflare{ - secret: s, - } -} - -func (provider *ProviderCloudflare) UpdateDomain(domainConfig *DomainConfig) error { - if domainConfig == nil { - return fmt.Errorf("获取 DDNS 配置失败") - } - provider.domainConfig = domainConfig - - err := provider.getZoneID() - if err != nil { - return fmt.Errorf("无法获取 zone ID: %s", err) - } - - // 当IPv4和IPv6同时成功才算作成功 - if provider.domainConfig.EnableIPv4 { - provider.isIpv4 = true - provider.recordType = getRecordString(provider.isIpv4) - provider.ipAddr = provider.domainConfig.Ipv4Addr - if err = provider.addDomainRecord(); err != nil { - return err - } - } - - if provider.domainConfig.EnableIpv6 { - provider.isIpv4 = false - provider.recordType = getRecordString(provider.isIpv4) - provider.ipAddr = provider.domainConfig.Ipv6Addr - if err = provider.addDomainRecord(); err != nil { - return err - } - } - - return nil -} - -func (provider *ProviderCloudflare) addDomainRecord() error { - err := provider.findDNSRecord() - if err != nil { - if errors.Is(err, utils.ErrGjsonNotFound) { - // 添加 DNS 记录 - return provider.createDNSRecord() - } - return fmt.Errorf("查找 DNS 记录时出错: %s", err) - } - - // 更新 DNS 记录 - return provider.updateDNSRecord() -} - -func (provider *ProviderCloudflare) getZoneID() error { - _, realDomain := splitDomain(provider.domainConfig.FullDomain) - zu, _ := url.Parse(baseEndpoint) - - q := zu.Query() - q.Set("name", realDomain) - zu.RawQuery = q.Encode() - - body, err := provider.sendRequest("GET", zu.String(), nil) - if err != nil { - return err - } - - result, err := utils.GjsonGet(body, "result.0.id") - if err != nil { - return err - } - - provider.zoneId = result.String() - return nil -} - -func (provider *ProviderCloudflare) findDNSRecord() error { - de, _ := url.JoinPath(baseEndpoint, provider.zoneId, "dns_records") - du, _ := url.Parse(de) - - q := du.Query() - q.Set("name", provider.domainConfig.FullDomain) - q.Set("type", provider.recordType) - du.RawQuery = q.Encode() - - body, err := provider.sendRequest("GET", du.String(), nil) - if err != nil { - return err - } - - result, err := utils.GjsonGet(body, "result.0.id") - if err != nil { - return err - } - - provider.recordId = result.String() - return nil -} - -func (provider *ProviderCloudflare) createDNSRecord() error { - de, _ := url.JoinPath(baseEndpoint, provider.zoneId, "dns_records") - data := &cfReq{ - Name: provider.domainConfig.FullDomain, - Type: provider.recordType, - Content: provider.ipAddr, - TTL: 60, - Proxied: false, - } - - jsonData, _ := utils.Json.Marshal(data) - _, err := provider.sendRequest("POST", de, jsonData) - return err -} - -func (provider *ProviderCloudflare) updateDNSRecord() error { - de, _ := url.JoinPath(baseEndpoint, provider.zoneId, "dns_records", provider.recordId) - data := &cfReq{ - Name: provider.domainConfig.FullDomain, - Type: provider.recordType, - Content: provider.ipAddr, - TTL: 60, - Proxied: false, - } - - jsonData, _ := utils.Json.Marshal(data) - _, err := provider.sendRequest("PATCH", de, jsonData) - return err -} - -// 以下为辅助方法,如发送 HTTP 请求等 -func (provider *ProviderCloudflare) sendRequest(method string, url string, data []byte) ([]byte, error) { - req, err := http.NewRequest(method, url, bytes.NewBuffer(data)) - if err != nil { - return nil, err - } - - req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", provider.secret)) - req.Header.Add("Content-Type", "application/json") - - resp, err := utils.HttpClient.Do(req) - if err != nil { - return nil, err - } - defer func(Body io.ReadCloser) { - err := Body.Close() - if err != nil { - log.Printf("NEZHA>> 无法关闭HTTP响应体流: %s", err.Error()) - } - }(resp.Body) - - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - - return body, nil -} diff --git a/pkg/ddns/ddns.go b/pkg/ddns/ddns.go index b3e45ff..56d1a5c 100644 --- a/pkg/ddns/ddns.go +++ b/pkg/ddns/ddns.go @@ -1,24 +1,121 @@ package ddns -import "golang.org/x/net/publicsuffix" +import ( + "context" + "fmt" + "log" + "time" -type DomainConfig struct { - EnableIPv4 bool - EnableIpv6 bool - FullDomain string - Ipv4Addr string - Ipv6Addr string + "github.com/libdns/libdns" + "github.com/miekg/dns" + + "github.com/naiba/nezha/model" + "github.com/naiba/nezha/pkg/utils" +) + +var dnsTimeOut = 10 * time.Second + +type IP struct { + Ipv4Addr string + Ipv6Addr string } -type Provider interface { - // UpdateDomain Return is updated - UpdateDomain(*DomainConfig) error +type Provider struct { + ctx context.Context + ipAddr string + recordType string + domain string + prefix string + zone string + + DDNSProfile *model.DDNSProfile + IPAddrs *IP + Setter libdns.RecordSetter } -func splitDomain(domain string) (prefix string, realDomain string) { - realDomain, _ = publicsuffix.EffectiveTLDPlusOne(domain) - prefix = domain[:len(domain)-len(realDomain)-1] - return prefix, realDomain +func (provider *Provider) UpdateDomain(ctx context.Context) { + provider.ctx = ctx + for _, domain := range provider.DDNSProfile.Domains { + for retries := 0; retries < int(provider.DDNSProfile.MaxRetries); retries++ { + provider.domain = domain + log.Printf("NEZHA>> 正在尝试更新域名(%s)DDNS(%d/%d)", provider.domain, retries+1, provider.DDNSProfile.MaxRetries) + if err := provider.updateDomain(); err != nil { + log.Printf("NEZHA>> 尝试更新域名(%s)DDNS失败: %v", provider.domain, err) + } else { + log.Printf("NEZHA>> 尝试更新域名(%s)DDNS成功", provider.domain) + break + } + } + } +} + +func (provider *Provider) updateDomain() error { + var err error + provider.prefix, provider.zone, err = splitDomainSOA(provider.domain) + if err != nil { + return err + } + + // 当IPv4和IPv6同时成功才算作成功 + if *provider.DDNSProfile.EnableIPv4 { + provider.recordType = getRecordString(true) + provider.ipAddr = provider.IPAddrs.Ipv4Addr + if err = provider.addDomainRecord(); err != nil { + return err + } + } + + if *provider.DDNSProfile.EnableIPv6 { + provider.recordType = getRecordString(false) + provider.ipAddr = provider.IPAddrs.Ipv6Addr + if err = provider.addDomainRecord(); err != nil { + return err + } + } + + return nil +} + +func (provider *Provider) addDomainRecord() error { + _, err := provider.Setter.SetRecords(provider.ctx, provider.zone, + []libdns.Record{ + { + Type: provider.recordType, + Name: provider.prefix, + Value: provider.ipAddr, + TTL: time.Minute, + }, + }) + return err +} + +func splitDomainSOA(domain string) (prefix string, zone string, err error) { + c := &dns.Client{Timeout: dnsTimeOut} + + domain += "." + indexes := dns.Split(domain) + + var r *dns.Msg + for _, idx := range indexes { + m := new(dns.Msg) + m.SetQuestion(domain[idx:], dns.TypeSOA) + + for _, server := range utils.DNSServers { + r, _, err = c.Exchange(m, server) + if err != nil { + return + } + if len(r.Answer) > 0 { + if soa, ok := r.Answer[0].(*dns.SOA); ok { + zone = soa.Hdr.Name + prefix = domain[:len(domain)-len(zone)-1] + return + } + } + } + } + + return "", "", fmt.Errorf("SOA record not found for domain: %s", domain) } func getRecordString(isIpv4 bool) string { diff --git a/pkg/ddns/ddns_test.go b/pkg/ddns/ddns_test.go new file mode 100644 index 0000000..1906133 --- /dev/null +++ b/pkg/ddns/ddns_test.go @@ -0,0 +1,44 @@ +package ddns + +import ( + "os" + "testing" +) + +type testSt struct { + domain string + zone string + prefix string +} + +func TestSplitDomainSOA(t *testing.T) { + if ci := os.Getenv("CI"); ci != "" { // skip if test on CI + return + } + + cases := []testSt{ + { + domain: "www.example.co.uk", + zone: "example.co.uk.", + prefix: "www", + }, + { + domain: "abc.example.com", + zone: "example.com.", + prefix: "abc", + }, + } + + for _, c := range cases { + prefix, zone, err := splitDomainSOA(c.domain) + if err != nil { + t.Fatalf("Error: %s", err) + } + if prefix != c.prefix { + t.Fatalf("Expected prefix %s, but got %s", c.prefix, prefix) + } + if zone != c.zone { + t.Fatalf("Expected zone %s, but got %s", c.zone, zone) + } + } +} diff --git a/pkg/ddns/dummy.go b/pkg/ddns/dummy.go deleted file mode 100644 index 0216871..0000000 --- a/pkg/ddns/dummy.go +++ /dev/null @@ -1,7 +0,0 @@ -package ddns - -type ProviderDummy struct{} - -func (provider *ProviderDummy) UpdateDomain(domainConfig *DomainConfig) error { - return nil -} diff --git a/pkg/ddns/dummy/dummy.go b/pkg/ddns/dummy/dummy.go new file mode 100644 index 0000000..310ffd8 --- /dev/null +++ b/pkg/ddns/dummy/dummy.go @@ -0,0 +1,16 @@ +package dummy + +import ( + "context" + + "github.com/libdns/libdns" +) + +// Internal use +type Provider struct { +} + +func (provider *Provider) SetRecords(ctx context.Context, zone string, + recs []libdns.Record) ([]libdns.Record, error) { + return recs, nil +} diff --git a/pkg/ddns/tencentcloud.go b/pkg/ddns/tencentcloud.go deleted file mode 100644 index 61d7568..0000000 --- a/pkg/ddns/tencentcloud.go +++ /dev/null @@ -1,243 +0,0 @@ -package ddns - -import ( - "bytes" - "crypto/hmac" - "crypto/sha256" - "encoding/hex" - "errors" - "fmt" - "io" - "log" - "net/http" - "strconv" - "strings" - "time" - - "github.com/naiba/nezha/pkg/utils" -) - -const te = "https://dnspod.tencentcloudapi.com" - -type ProviderTencentCloud struct { - isIpv4 bool - domainConfig *DomainConfig - recordID uint64 - recordType string - secretID string - secretKey string - errCode string - ipAddr string -} - -type tcReq struct { - RecordType string `json:"RecordType"` - Domain string `json:"Domain"` - RecordLine string `json:"RecordLine"` - Subdomain string `json:"Subdomain,omitempty"` - SubDomain string `json:"SubDomain,omitempty"` // As is - Value string `json:"Value,omitempty"` - TTL uint32 `json:"TTL,omitempty"` - RecordId uint64 `json:"RecordId,omitempty"` -} - -func NewProviderTencentCloud(id, key string) *ProviderTencentCloud { - return &ProviderTencentCloud{ - secretID: id, - secretKey: key, - } -} - -func (provider *ProviderTencentCloud) UpdateDomain(domainConfig *DomainConfig) error { - if domainConfig == nil { - return fmt.Errorf("获取 DDNS 配置失败") - } - provider.domainConfig = domainConfig - - // 当IPv4和IPv6同时成功才算作成功 - var err error - if provider.domainConfig.EnableIPv4 { - provider.isIpv4 = true - provider.recordType = getRecordString(provider.isIpv4) - provider.ipAddr = provider.domainConfig.Ipv4Addr - if err = provider.addDomainRecord(); err != nil { - return err - } - } - - if provider.domainConfig.EnableIpv6 { - provider.isIpv4 = false - provider.recordType = getRecordString(provider.isIpv4) - provider.ipAddr = provider.domainConfig.Ipv6Addr - if err = provider.addDomainRecord(); err != nil { - return err - } - } - - return err -} - -func (provider *ProviderTencentCloud) addDomainRecord() error { - err := provider.findDNSRecord() - if err != nil { - return fmt.Errorf("查找 DNS 记录时出错: %s", err) - } - - if provider.errCode == "ResourceNotFound.NoDataOfRecord" { // 没有找到 DNS 记录 - return provider.createDNSRecord() - } else if provider.errCode != "" { - return fmt.Errorf("查询 DNS 记录时出错,错误代码为: %s", provider.errCode) - } - - // 默认情况下更新 DNS 记录 - return provider.updateDNSRecord() -} - -func (provider *ProviderTencentCloud) findDNSRecord() error { - prefix, realDomain := splitDomain(provider.domainConfig.FullDomain) - data := &tcReq{ - RecordType: provider.recordType, - Domain: realDomain, - RecordLine: "默认", - Subdomain: prefix, - } - - jsonData, _ := utils.Json.Marshal(data) - body, err := provider.sendRequest("DescribeRecordList", jsonData) - if err != nil { - return err - } - - result, err := utils.GjsonGet(body, "Response.RecordList.0.RecordId") - if err != nil { - if errors.Is(err, utils.ErrGjsonNotFound) { - if errCode, err := utils.GjsonGet(body, "Response.Error.Code"); err == nil { - provider.errCode = errCode.String() - return nil - } - } - return err - } - - provider.recordID = result.Uint() - return nil -} - -func (provider *ProviderTencentCloud) createDNSRecord() error { - prefix, realDomain := splitDomain(provider.domainConfig.FullDomain) - data := &tcReq{ - RecordType: provider.recordType, - RecordLine: "默认", - Domain: realDomain, - SubDomain: prefix, - Value: provider.ipAddr, - TTL: 600, - } - - jsonData, _ := utils.Json.Marshal(data) - _, err := provider.sendRequest("CreateRecord", jsonData) - return err -} - -func (provider *ProviderTencentCloud) updateDNSRecord() error { - prefix, realDomain := splitDomain(provider.domainConfig.FullDomain) - data := &tcReq{ - RecordType: provider.recordType, - RecordLine: "默认", - Domain: realDomain, - SubDomain: prefix, - Value: provider.ipAddr, - TTL: 600, - RecordId: provider.recordID, - } - - jsonData, _ := utils.Json.Marshal(data) - _, err := provider.sendRequest("ModifyRecord", jsonData) - return err -} - -// 以下为辅助方法,如发送 HTTP 请求等 -func (provider *ProviderTencentCloud) sendRequest(action string, data []byte) ([]byte, error) { - req, err := http.NewRequest("POST", te, bytes.NewBuffer(data)) - if err != nil { - return nil, err - } - - req.Header.Set("Content-Type", "application/json") - req.Header.Set("X-TC-Version", "2021-03-23") - - provider.signRequest(provider.secretID, provider.secretKey, req, action, string(data)) - resp, err := utils.HttpClient.Do(req) - if err != nil { - return nil, err - } - defer func(Body io.ReadCloser) { - err := Body.Close() - if err != nil { - log.Printf("NEZHA>> 无法关闭HTTP响应体流: %s\n", err.Error()) - } - }(resp.Body) - - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - - return body, nil -} - -// https://github.com/jeessy2/ddns-go/blob/master/util/tencent_cloud_signer.go - -func (provider *ProviderTencentCloud) sha256hex(s string) string { - b := sha256.Sum256([]byte(s)) - return hex.EncodeToString(b[:]) -} - -func (provider *ProviderTencentCloud) hmacsha256(s, key string) string { - hashed := hmac.New(sha256.New, []byte(key)) - hashed.Write([]byte(s)) - return string(hashed.Sum(nil)) -} - -func (provider *ProviderTencentCloud) WriteString(strs ...string) string { - var b strings.Builder - for _, str := range strs { - b.WriteString(str) - } - - return b.String() -} - -func (provider *ProviderTencentCloud) signRequest(secretId string, secretKey string, r *http.Request, action string, payload string) { - algorithm := "TC3-HMAC-SHA256" - service := "dnspod" - host := provider.WriteString(service, ".tencentcloudapi.com") - timestamp := time.Now().Unix() - timestampStr := strconv.FormatInt(timestamp, 10) - - // 步骤 1:拼接规范请求串 - canonicalHeaders := provider.WriteString("content-type:application/json\nhost:", host, "\nx-tc-action:", strings.ToLower(action), "\n") - signedHeaders := "content-type;host;x-tc-action" - hashedRequestPayload := provider.sha256hex(payload) - canonicalRequest := provider.WriteString("POST\n/\n\n", canonicalHeaders, "\n", signedHeaders, "\n", hashedRequestPayload) - - // 步骤 2:拼接待签名字符串 - date := time.Unix(timestamp, 0).UTC().Format("2006-01-02") - credentialScope := provider.WriteString(date, "/", service, "/tc3_request") - hashedCanonicalRequest := provider.sha256hex(canonicalRequest) - string2sign := provider.WriteString(algorithm, "\n", timestampStr, "\n", credentialScope, "\n", hashedCanonicalRequest) - - // 步骤 3:计算签名 - secretDate := provider.hmacsha256(date, provider.WriteString("TC3", secretKey)) - secretService := provider.hmacsha256(service, secretDate) - secretSigning := provider.hmacsha256("tc3_request", secretService) - signature := hex.EncodeToString([]byte(provider.hmacsha256(string2sign, secretSigning))) - - // 步骤 4:拼接 Authorization - authorization := provider.WriteString(algorithm, " Credential=", secretId, "/", credentialScope, ", SignedHeaders=", signedHeaders, ", Signature=", signature) - - r.Header.Add("Authorization", authorization) - r.Header.Set("Host", host) - r.Header.Set("X-TC-Action", action) - r.Header.Add("X-TC-Timestamp", timestampStr) -} diff --git a/pkg/ddns/webhook.go b/pkg/ddns/webhook.go deleted file mode 100644 index 74f0570..0000000 --- a/pkg/ddns/webhook.go +++ /dev/null @@ -1,110 +0,0 @@ -package ddns - -import ( - "bytes" - "fmt" - "net/http" - "net/url" - "strings" - - "github.com/naiba/nezha/pkg/utils" -) - -type ProviderWebHook struct { - url string - requestMethod string - requestBody string - requestHeader string - domainConfig *DomainConfig -} - -func NewProviderWebHook(s, rm, rb, rh string) *ProviderWebHook { - return &ProviderWebHook{ - url: s, - requestMethod: rm, - requestBody: rb, - requestHeader: rh, - } -} - -func (provider *ProviderWebHook) UpdateDomain(domainConfig *DomainConfig) error { - if domainConfig == nil { - return fmt.Errorf("获取 DDNS 配置失败") - } - provider.domainConfig = domainConfig - - if provider.domainConfig.FullDomain == "" { - return fmt.Errorf("failed to update an empty domain") - } - - if provider.domainConfig.EnableIPv4 && provider.domainConfig.Ipv4Addr != "" { - req, err := provider.prepareRequest(true) - if err != nil { - return fmt.Errorf("failed to update a domain: %s. Cause by: %v", provider.domainConfig.FullDomain, err) - } - if _, err := utils.HttpClient.Do(req); err != nil { - return fmt.Errorf("failed to update a domain: %s. Cause by: %v", provider.domainConfig.FullDomain, err) - } - } - - if provider.domainConfig.EnableIpv6 && provider.domainConfig.Ipv6Addr != "" { - req, err := provider.prepareRequest(false) - if err != nil { - return fmt.Errorf("failed to update a domain: %s. Cause by: %v", provider.domainConfig.FullDomain, err) - } - if _, err := utils.HttpClient.Do(req); err != nil { - return fmt.Errorf("failed to update a domain: %s. Cause by: %v", provider.domainConfig.FullDomain, err) - } - } - return nil -} - -func (provider *ProviderWebHook) prepareRequest(isIPv4 bool) (*http.Request, error) { - u, err := url.Parse(provider.url) - if err != nil { - return nil, fmt.Errorf("failed parsing url: %v", err) - } - - // Only handle queries here - q := u.Query() - for p, vals := range q { - for n, v := range vals { - vals[n] = provider.formatWebhookString(v, isIPv4) - } - q[p] = vals - } - - u.RawQuery = q.Encode() - body := provider.formatWebhookString(provider.requestBody, isIPv4) - header := provider.formatWebhookString(provider.requestHeader, isIPv4) - headers := strings.Split(header, "\n") - - req, err := http.NewRequest(provider.requestMethod, u.String(), bytes.NewBufferString(body)) - if err != nil { - return nil, fmt.Errorf("failed creating new request: %v", err) - } - - utils.SetStringHeadersToRequest(req, headers) - return req, nil -} - -func (provider *ProviderWebHook) formatWebhookString(s string, isIPv4 bool) string { - var ipAddr, ipType string - if isIPv4 { - ipAddr = provider.domainConfig.Ipv4Addr - ipType = "ipv4" - } else { - ipAddr = provider.domainConfig.Ipv6Addr - ipType = "ipv6" - } - - r := strings.NewReplacer( - "{ip}", ipAddr, - "{domain}", provider.domainConfig.FullDomain, - "{type}", ipType, - "\r", "", - ) - - result := r.Replace(strings.TrimSpace(s)) - return result -} diff --git a/pkg/ddns/webhook/webhook.go b/pkg/ddns/webhook/webhook.go new file mode 100644 index 0000000..fde7199 --- /dev/null +++ b/pkg/ddns/webhook/webhook.go @@ -0,0 +1,178 @@ +package webhook + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/url" + "strings" + + "github.com/libdns/libdns" + "github.com/naiba/nezha/model" + "github.com/naiba/nezha/pkg/utils" +) + +const ( + _ = iota + methodGET + methodPOST + methodPATCH + methodDELETE + methodPUT +) + +const ( + _ = iota + requestTypeJSON + requestTypeForm +) + +var requestTypes = map[uint8]string{ + methodGET: "GET", + methodPOST: "POST", + methodPATCH: "PATCH", + methodDELETE: "DELETE", + methodPUT: "PUT", +} + +// Internal use +type Provider struct { + ipAddr string + ipType string + recordType string + domain string + + DDNSProfile *model.DDNSProfile +} + +func (provider *Provider) SetRecords(ctx context.Context, zone string, + recs []libdns.Record) ([]libdns.Record, error) { + for _, rec := range recs { + provider.recordType = rec.Type + provider.ipType = recordToIPType(provider.recordType) + provider.ipAddr = rec.Value + provider.domain = fmt.Sprintf("%s.%s", rec.Name, strings.TrimSuffix(zone, ".")) + + req, err := provider.prepareRequest(ctx) + if err != nil { + return nil, fmt.Errorf("failed to update a domain: %s. Cause by: %v", provider.domain, err) + } + if _, err := utils.HttpClient.Do(req); err != nil { + return nil, fmt.Errorf("failed to update a domain: %s. Cause by: %v", provider.domain, err) + } + } + + return recs, nil +} + +func (provider *Provider) prepareRequest(ctx context.Context) (*http.Request, error) { + u, err := provider.reqUrl() + if err != nil { + return nil, err + } + + body, err := provider.reqBody() + if err != nil { + return nil, err + } + + headers, err := utils.GjsonParseStringMap( + provider.formatWebhookString(provider.DDNSProfile.WebhookHeaders)) + if err != nil { + return nil, err + } + + req, err := http.NewRequestWithContext(ctx, requestTypes[provider.DDNSProfile.WebhookMethod], u.String(), strings.NewReader(body)) + if err != nil { + return nil, err + } + + provider.setContentType(req) + + for k, v := range headers { + req.Header.Set(k, v) + } + + return req, nil +} + +func (provider *Provider) setContentType(req *http.Request) { + if provider.DDNSProfile.WebhookMethod == methodGET { + return + } + if provider.DDNSProfile.WebhookRequestType == requestTypeForm { + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + } else { + req.Header.Set("Content-Type", "application/json") + } +} + +func (provider *Provider) reqUrl() (*url.URL, error) { + formattedUrl := strings.ReplaceAll(provider.DDNSProfile.WebhookURL, "#", "%23") + + u, err := url.Parse(formattedUrl) + if err != nil { + return nil, err + } + + // Only handle queries here + q := u.Query() + for p, vals := range q { + for n, v := range vals { + vals[n] = provider.formatWebhookString(v) + } + q[p] = vals + } + + u.RawQuery = q.Encode() + return u, nil +} + +func (provider *Provider) reqBody() (string, error) { + if provider.DDNSProfile.WebhookMethod == methodGET || + provider.DDNSProfile.WebhookMethod == methodDELETE { + return "", nil + } + + switch provider.DDNSProfile.WebhookRequestType { + case requestTypeJSON: + return provider.formatWebhookString(provider.DDNSProfile.WebhookRequestBody), nil + case requestTypeForm: + data, err := utils.GjsonParseStringMap(provider.DDNSProfile.WebhookRequestBody) + if err != nil { + return "", err + } + params := url.Values{} + for k, v := range data { + params.Add(k, provider.formatWebhookString(v)) + } + return params.Encode(), nil + default: + return "", errors.New("request type not supported") + } +} + +func (provider *Provider) formatWebhookString(s string) string { + r := strings.NewReplacer( + "#ip#", provider.ipAddr, + "#domain#", provider.domain, + "#type#", provider.ipType, + "#record#", provider.recordType, + "\r", "", + ) + + result := r.Replace(strings.TrimSpace(s)) + return result +} + +func recordToIPType(record string) string { + switch record { + case "A": + return "ipv4" + case "AAAA": + return "ipv6" + default: + return "" + } +} diff --git a/pkg/ddns/webhook/webhook_test.go b/pkg/ddns/webhook/webhook_test.go new file mode 100644 index 0000000..7b6e96a --- /dev/null +++ b/pkg/ddns/webhook/webhook_test.go @@ -0,0 +1,116 @@ +package webhook + +import ( + "context" + "testing" + + "github.com/naiba/nezha/model" +) + +var ( + reqTypeForm = "application/x-www-form-urlencoded" + reqTypeJSON = "application/json" +) + +type testSt struct { + profile model.DDNSProfile + expectURL string + expectBody string + expectContentType string + expectHeader map[string]string +} + +func execCase(t *testing.T, item testSt) { + pw := Provider{DDNSProfile: &item.profile} + pw.ipAddr = "1.1.1.1" + pw.domain = item.profile.Domains[0] + pw.ipType = "ipv4" + pw.recordType = "A" + pw.DDNSProfile = &item.profile + + reqUrl, err := pw.reqUrl() + if err != nil { + t.Fatalf("Error: %s", err) + } + if item.expectURL != reqUrl.String() { + t.Fatalf("Expected %s, but got %s", item.expectURL, reqUrl.String()) + } + + reqBody, err := pw.reqBody() + if err != nil { + t.Fatalf("Error: %s", err) + } + if item.expectBody != reqBody { + t.Fatalf("Expected %s, but got %s", item.expectBody, reqBody) + } + + req, err := pw.prepareRequest(context.Background()) + if err != nil { + t.Fatalf("Error: %s", err) + } + + if item.expectContentType != req.Header.Get("Content-Type") { + t.Fatalf("Expected %s, but got %s", item.expectContentType, req.Header.Get("Content-Type")) + } + + for k, v := range item.expectHeader { + if v != req.Header.Get(k) { + t.Fatalf("Expected %s, but got %s", v, req.Header.Get(k)) + } + } +} + +func TestWebhookRequest(t *testing.T) { + ipv4 := true + + cases := []testSt{ + { + profile: model.DDNSProfile{ + Domains: []string{"www.example.com"}, + MaxRetries: 1, + EnableIPv4: &ipv4, + WebhookURL: "http://ddns.example.com/?ip=#ip#", + WebhookMethod: methodGET, + WebhookHeaders: `{"ip":"#ip#","record":"#record#"}`, + }, + expectURL: "http://ddns.example.com/?ip=1.1.1.1", + expectContentType: "", + expectHeader: map[string]string{ + "ip": "1.1.1.1", + "record": "A", + }, + }, + { + profile: model.DDNSProfile{ + Domains: []string{"www.example.com"}, + MaxRetries: 1, + EnableIPv4: &ipv4, + WebhookURL: "http://ddns.example.com/api", + WebhookMethod: methodPOST, + WebhookRequestType: requestTypeJSON, + WebhookRequestBody: `{"ip":"#ip#","record":"#record#"}`, + }, + expectURL: "http://ddns.example.com/api", + expectContentType: reqTypeJSON, + expectBody: `{"ip":"1.1.1.1","record":"A"}`, + }, + { + profile: model.DDNSProfile{ + Domains: []string{"www.example.com"}, + MaxRetries: 1, + EnableIPv4: &ipv4, + WebhookURL: "http://ddns.example.com/api", + WebhookMethod: methodPOST, + WebhookRequestType: requestTypeForm, + WebhookRequestBody: `{"ip":"#ip#","record":"#record#"}`, + }, + expectURL: "http://ddns.example.com/api", + expectContentType: reqTypeForm, + expectBody: "ip=1.1.1.1&record=A", + }, + } + + for _, c := range cases { + execCase(t, c) + } +} diff --git a/pkg/mygin/mygin.go b/pkg/mygin/mygin.go index 78d6c1a..08133bd 100644 --- a/pkg/mygin/mygin.go +++ b/pkg/mygin/mygin.go @@ -16,6 +16,7 @@ var adminPage = map[string]bool{ "/monitor": true, "/setting": true, "/notification": true, + "/ddns": true, "/nat": true, "/cron": true, "/api": true, diff --git a/pkg/utils/gjson.go b/pkg/utils/gjson.go index de377dd..0063681 100644 --- a/pkg/utils/gjson.go +++ b/pkg/utils/gjson.go @@ -21,6 +21,10 @@ func GjsonGet(json []byte, path string) (gjson.Result, error) { } func GjsonParseStringMap(jsonObject string) (map[string]string, error) { + if jsonObject == "" { + return nil, nil + } + result := gjson.Parse(jsonObject) if !result.IsObject() { return nil, ErrGjsonWrongType diff --git a/pkg/utils/http.go b/pkg/utils/http.go index 6d9cc22..a792bf9 100644 --- a/pkg/utils/http.go +++ b/pkg/utils/http.go @@ -22,6 +22,8 @@ func init() { SkipVerifySSL: false, }), }) + + http.DefaultClient.Timeout = time.Minute * 10 } type _httpTransport struct { diff --git a/pkg/utils/utils.go b/pkg/utils/utils.go index a5caec7..4c8e2ea 100644 --- a/pkg/utils/utils.go +++ b/pkg/utils/utils.go @@ -3,7 +3,6 @@ package utils import ( "crypto/rand" "math/big" - "net/http" "os" "regexp" "strings" @@ -11,7 +10,11 @@ import ( jsoniter "github.com/json-iterator/go" ) -var Json = jsoniter.ConfigCompatibleWithStandardLibrary +var ( + Json = jsoniter.ConfigCompatibleWithStandardLibrary + + DNSServers = []string{"1.1.1.1:53", "223.5.5.5:53", "[2606:4700:4700::1111]:53", "[2400:3200::1]:53"} +) func IsWindows() bool { return os.PathSeparator == '\\' && os.PathListSeparator == ';' @@ -87,15 +90,3 @@ func Uint64SubInt64(a uint64, b int64) uint64 { } return a - uint64(b) } - -func SetStringHeadersToRequest(req *http.Request, headers []string) { - if req == nil { - return - } - for _, element := range headers { - kv := strings.SplitN(element, ":", 2) - if len(kv) == 2 { - req.Header.Add(kv[0], kv[1]) - } - } -} diff --git a/resource/l10n/en-US.toml b/resource/l10n/en-US.toml index 2b3123a..c96fd94 100644 --- a/resource/l10n/en-US.toml +++ b/resource/l10n/en-US.toml @@ -622,20 +622,59 @@ other = "Network" [EnableShowInService] other = "Enable Show in Service" +[DDNS] +other = "Dynamic DNS" + +[DDNSProfiles] +other = "DDNS Profiles" + +[AddDDNSProfile] +other = "New Profile" + [EnableDDNS] other = "Enable DDNS" [EnableIPv4] -other = "Enable DDNS IPv4" +other = "IPv4 Enabled" -[EnableIpv6] -other = "Enable DDNS IPv6" +[EnableIPv6] +other = "IPv6 Enabled" [DDNSDomain] -other = "DDNS Domain" +other = "Domains" -[DDNSProfile] -other = "DDNS Profile Name" +[DDNSDomains] +other = "Domains (separate with comma)" + +[DDNSProvider] +other = "DDNS Provider" + +[MaxRetries] +other = "Maximum retry attempts" + +[DDNSAccessID] +other = "Credential 1" + +[DDNSAccessSecret] +other = "Credential 2" + +[DDNSTokenID] +other = "Token ID" + +[DDNSTokenSecret] +other = "Token Secret" + +[WebhookURL] +other = "Webhook URL" + +[WebhookMethod] +other = "Webhook Request Method" + +[WebhookHeaders] +other = "Webhook Request Headers" + +[WebhookRequestBody] +other = "Webhook Request Body" [Feature] other = "Feature" diff --git a/resource/l10n/es-ES.toml b/resource/l10n/es-ES.toml index 1725255..d412fed 100644 --- a/resource/l10n/es-ES.toml +++ b/resource/l10n/es-ES.toml @@ -622,20 +622,59 @@ other = "Red" [EnableShowInService] other = "Mostrar en servicio" +[DDNS] +other = "DNS Dinámico" + +[DDNSProfiles] +other = "Perfiles DDNS" + +[AddDDNSProfile] +other = "Nuevo Perfil" + [EnableDDNS] other = "Habilitar DDNS" [EnableIPv4] -other = "Habilitar DDNS IPv4" +other = "IPv4 Activado" -[EnableIpv6] -other = "Habilitar DDNS IPv6" +[EnableIPv6] +other = "IPv6 Activado" [DDNSDomain] -other = "Dominio DDNS" +other = "Dominios" -[DDNSProfile] -other = "Nombre del perfil de DDNS" +[DDNSDomains] +other = "Dominios (separados por comas)" + +[DDNSProvider] +other = "Proveedor DDNS" + +[MaxRetries] +other = "Número máximo de intentos de reintento" + +[DDNSAccessID] +other = "Credencial 1" + +[DDNSAccessSecret] +other = "Credencial 2" + +[DDNSTokenID] +other = "ID del Token" + +[DDNSTokenSecret] +other = "Secreto del Token" + +[WebhookURL] +other = "URL del Webhook" + +[WebhookMethod] +other = "Método de Solicitud del Webhook" + +[WebhookHeaders] +other = "Encabezados de Solicitud del Webhook" + +[WebhookRequestBody] +other = "Cuerpo de Solicitud del Webhook" [Feature] other = "Característica" diff --git a/resource/l10n/zh-CN.toml b/resource/l10n/zh-CN.toml index 3c9ff43..a01e099 100644 --- a/resource/l10n/zh-CN.toml +++ b/resource/l10n/zh-CN.toml @@ -622,20 +622,59 @@ other = "网络" [EnableShowInService] other = "在服务中显示" +[DDNS] +other = "动态 DNS" + +[DDNSProfiles] +other = "DDNS配置" + +[AddDDNSProfile] +other = "新配置" + [EnableDDNS] other = "启用DDNS" [EnableIPv4] other = "启用DDNS IPv4" -[EnableIpv6] +[EnableIPv6] other = "启用DDNS IPv6" [DDNSDomain] other = "DDNS域名" -[DDNSProfile] -other = "DDNS配置名" +[DDNSDomains] +other = "域名(逗号分隔)" + +[DDNSProvider] +other = "DDNS供应商" + +[MaxRetries] +other = "最大重试次数" + +[DDNSAccessID] +other = "DDNS 凭据 1" + +[DDNSAccessSecret] +other = "DDNS 凭据 2" + +[DDNSTokenID] +other = "令牌 ID" + +[DDNSTokenSecret] +other = "令牌 Secret" + +[WebhookURL] +other = "Webhook 地址" + +[WebhookMethod] +other = "Webhook 请求方式" + +[WebhookHeaders] +other = "Webhook 请求头" + +[WebhookRequestBody] +other = "Webhook 请求体" [Feature] other = "功能" diff --git a/resource/l10n/zh-TW.toml b/resource/l10n/zh-TW.toml index 732cc3a..f4ab873 100644 --- a/resource/l10n/zh-TW.toml +++ b/resource/l10n/zh-TW.toml @@ -622,20 +622,59 @@ other = "網路" [EnableShowInService] other = "在服務中顯示" +[DDNS] +other = "動態 DNS" + +[DDNSProfiles] +other = "DDNS配置" + +[AddDDNSProfile] +other = "新增配置" + [EnableDDNS] other = "啟用DDNS" [EnableIPv4] other = "啟用DDNS IPv4" -[EnableIpv6] +[EnableIPv6] other = "啟用DDNS IPv6" [DDNSDomain] -other = "DDNS網域" +other = "DDNS域名" -[DDNSProfile] -other = "DDNS設定名" +[DDNSDomains] +other = "域名(逗號分隔)" + +[DDNSProvider] +other = "DDNS供應商" + +[MaxRetries] +other = "最大重試次數" + +[DDNSAccessID] +other = "DDNS憑據1" + +[DDNSAccessSecret] +other = "DDNS憑據2" + +[DDNSTokenID] +other = "令牌ID" + +[DDNSTokenSecret] +other = "令牌Secret" + +[WebhookURL] +other = "Webhook地址" + +[WebhookMethod] +other = "Webhook請求方式" + +[WebhookHeaders] +other = "Webhook請求頭" + +[WebhookRequestBody] +other = "Webhook請求體" [Feature] other = "功能" diff --git a/resource/static/main.js b/resource/static/main.js index 28d31ea..da7c59a 100644 --- a/resource/static/main.js +++ b/resource/static/main.js @@ -99,7 +99,10 @@ function showFormModal(modelSelector, formID, URL, getData) { item.name === "DisplayIndex" || item.name === "Type" || item.name === "Cover" || - item.name === "Duration" + item.name === "Duration" || + item.name === "MaxRetries" || + item.name === "Provider" || + item.name === "WebhookMethod" ) { obj[item.name] = parseInt(item.value); } else if (item.name.endsWith("Latency")) { @@ -128,6 +131,16 @@ function showFormModal(modelSelector, formID, URL, getData) { } } + if (item.name.endsWith("DDNSProfilesRaw")) { + if (item.value.length > 2) { + obj[item.name] = JSON.stringify( + [...item.value.matchAll(/\d+/gm)].map((k) => + parseInt(k[0]) + ) + ); + } + } + return obj; }, {}); $.post(URL, JSON.stringify(data)) @@ -207,6 +220,7 @@ function addOrEditAlertRule(rule) { ); } } + // 需要在 showFormModal 进一步拼接数组 modal .find("input[name=FailTriggerTasksRaw]") .val(rule ? "[]," + failTriggerTasks.substr(1, failTriggerTasks.length - 2) : "[]"); @@ -256,6 +270,52 @@ function addOrEditNotification(notification) { ); } +function addOrEditDDNS(ddns) { + const modal = $(".ddns.modal"); + modal.children(".header").text((ddns ? LANG.Edit : LANG.Add)); + modal + .find(".nezha-primary-btn.button") + .html( + ddns + ? LANG.Edit + '' + : LANG.Add + '' + ); + modal.find("input[name=ID]").val(ddns ? ddns.ID : null); + modal.find("input[name=Name]").val(ddns ? ddns.Name : null); + modal.find("input[name=DomainsRaw]").val(ddns ? ddns.DomainsRaw : null); + modal.find("input[name=AccessID]").val(ddns ? ddns.AccessID : null); + modal.find("input[name=AccessSecret]").val(ddns ? ddns.AccessSecret : null); + modal.find("input[name=MaxRetries]").val(ddns ? ddns.MaxRetries : 3); + modal.find("input[name=WebhookURL]").val(ddns ? ddns.WebhookURL : null); + modal + .find("textarea[name=WebhookHeaders]") + .val(ddns ? ddns.WebhookHeaders : null); + modal + .find("textarea[name=WebhookRequestBody]") + .val(ddns ? ddns.WebhookRequestBody : null); + modal + .find("select[name=Provider]") + .val(ddns ? ddns.Provider : 0); + modal + .find("select[name=WebhookMethod]") + .val(ddns ? ddns.WebhookMethod : 1); + if (ddns && ddns.EnableIPv4) { + modal.find(".ui.enableipv4.checkbox").checkbox("set checked"); + } else { + modal.find(".ui.enableipv4.checkbox").checkbox("set unchecked"); + } + if (ddns && ddns.EnableIPv6) { + modal.find(".ui.enableipv6.checkbox").checkbox("set checked"); + } else { + modal.find(".ui.enableipv6.checkbox").checkbox("set unchecked"); + } + showFormModal( + ".ddns.modal", + "#ddnsForm", + "/api/ddns" + ); +} + function addOrEditNAT(nat) { const modal = $(".nat.modal"); modal.children(".header").text((nat ? LANG.Edit : LANG.Add)); @@ -325,8 +385,33 @@ function addOrEditServer(server, conf) { modal.find("input[name=id]").val(server ? server.ID : null); modal.find("input[name=name]").val(server ? server.Name : null); modal.find("input[name=Tag]").val(server ? server.Tag : null); - modal.find("input[name=DDNSDomain]").val(server ? server.DDNSDomain : null); - modal.find("input[name=DDNSProfile]").val(server ? server.DDNSProfile : null); + modal.find("a.ui.label.visible").each((i, el) => { + el.remove(); + }); + var ddns; + if (server) { + ddns = server.DDNSProfilesRaw; + let serverList; + try { + serverList = JSON.parse(ddns); + } catch (error) { + serverList = "[]"; + } + const node = modal.find("i.dropdown.icon.ddnsProfiles"); + for (let i = 0; i < serverList.length; i++) { + node.after( + 'ID:' + + serverList[i] + + '' + ); + } + } + // 需要在 showFormModal 进一步拼接数组 + modal + .find("input[name=DDNSProfilesRaw]") + .val(server ? "[]," + ddns.substr(1, ddns.length - 2) : "[]"); modal .find("input[name=DisplayIndex]") .val(server ? server.DisplayIndex : null); @@ -342,26 +427,17 @@ function addOrEditServer(server, conf) { modal.find(".command.field").attr("style", "display:none"); modal.find("input[name=secret]").val(""); } - if (server && server.HideForGuest) { - modal.find(".ui.hideforguest.checkbox").checkbox("set checked"); - } else { - modal.find(".ui.hideforguest.checkbox").checkbox("set unchecked"); - } if (server && server.EnableDDNS) { modal.find(".ui.enableddns.checkbox").checkbox("set checked"); } else { modal.find(".ui.enableddns.checkbox").checkbox("set unchecked"); } - if (server && server.EnableIPv4) { - modal.find(".ui.enableipv4.checkbox").checkbox("set checked"); + if (server && server.HideForGuest) { + modal.find(".ui.hideforguest.checkbox").checkbox("set checked"); } else { - modal.find(".ui.enableipv4.checkbox").checkbox("set unchecked"); - } - if (server && server.EnableIpv6) { - modal.find(".ui.enableipv6.checkbox").checkbox("set checked"); - } else { - modal.find(".ui.enableipv6.checkbox").checkbox("set unchecked"); + modal.find(".ui.hideforguest.checkbox").checkbox("set unchecked"); } + showFormModal(".server.modal", "#serverForm", "/api/server"); } @@ -447,6 +523,7 @@ function addOrEditMonitor(monitor) { ); } } + // 需要在 showFormModal 进一步拼接数组 modal .find("input[name=FailTriggerTasksRaw]") .val(monitor ? "[]," + failTriggerTasks.substr(1, failTriggerTasks.length - 2) : "[]"); @@ -492,6 +569,7 @@ function addOrEditCron(cron) { ); } } + // 需要在 showFormModal 进一步拼接数组 modal .find("input[name=ServersRaw]") .val(cron ? "[]," + servers.substr(1, servers.length - 2) : "[]"); @@ -621,3 +699,15 @@ $(document).ready(() => { }); } catch (error) { } }); + +$(document).ready(() => { + try { + $(".ui.ddns.search.dropdown").dropdown({ + clearable: true, + apiSettings: { + url: "/api/search-ddns?word={query}", + cache: false, + }, + }); + } catch (error) { } +}); diff --git a/resource/template/common/footer.html b/resource/template/common/footer.html index a787fa0..15752e6 100644 --- a/resource/template/common/footer.html +++ b/resource/template/common/footer.html @@ -10,7 +10,7 @@ - + +{{end}} \ No newline at end of file diff --git a/resource/template/dashboard-default/server.html b/resource/template/dashboard-default/server.html index 68c3827..09ad170 100644 --- a/resource/template/dashboard-default/server.html +++ b/resource/template/dashboard-default/server.html @@ -28,8 +28,8 @@