mirror of
https://github.com/Buriburizaem0n/nezha_domains.git
synced 2026-02-05 21:20:06 +00:00
Add DDNS support (#324)
* feat: add ddns updater framework Note: no functionality implemented yet * feat: add webhook ddns provider * feat: update dashboard template * fix: check nil and cron task string * fix: webhook string formated with unexcepted param * fix: webhook header split error * feat: cloudflare ddns provider * refract: move ddns update trigger into ReportSystemInfo * lang: update other languages text * fix: clear codes and logics * fix: move update ddns to goroutine to avoid blocking * fix: clear unused codes * fix: update timestamp to prevent cache
This commit is contained in:
@@ -3,6 +3,8 @@ package rpc
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/naiba/nezha/pkg/utils"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/jinzhu/copier"
|
||||
@@ -110,6 +112,36 @@ func (s *NezhaHandler) ReportSystemInfo(c context.Context, r *pb.Host) (*pb.Rece
|
||||
host := model.PB2Host(r)
|
||||
singleton.ServerLock.RLock()
|
||||
defer singleton.ServerLock.RUnlock()
|
||||
|
||||
// 检查并更新DDNS
|
||||
if singleton.Conf.DDNS.Enable &&
|
||||
singleton.ServerList[clientID].EnableDDNS &&
|
||||
singleton.ServerList[clientID].Host != nil &&
|
||||
host.IP != "" &&
|
||||
singleton.ServerList[clientID].Host.IP != host.IP {
|
||||
|
||||
serverDomain := singleton.ServerList[clientID].DDNSDomain
|
||||
provider, err := singleton.GetDDNSProviderFromString(singleton.Conf.DDNS.Provider)
|
||||
if err == nil && serverDomain != "" {
|
||||
ipv4, ipv6, _ := utils.SplitIPAddr(host.IP)
|
||||
maxRetries := int(singleton.Conf.DDNS.MaxRetries)
|
||||
config := &singleton.DDNSDomainConfig{
|
||||
EnableIPv4: true,
|
||||
EnableIpv6: true,
|
||||
FullDomain: serverDomain,
|
||||
Ipv4Addr: ipv4,
|
||||
Ipv6Addr: ipv6,
|
||||
}
|
||||
go singleton.RetryableUpdateDomain(provider, config, maxRetries)
|
||||
|
||||
} else {
|
||||
// 虽然会在启动时panic, 可以断言不会走这个分支, 但是考虑到动态加载配置或者其它情况, 这里输出一下方便检查奇奇怪怪的BUG
|
||||
log.Printf("NEZHA>> 未找到对应的DDNS提供者(%s), 请前往config.yml检查你的设置\n", singleton.Conf.DDNS.Provider)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// 发送IP变动通知
|
||||
if singleton.Conf.EnableIPChangeNotification &&
|
||||
((singleton.Conf.Cover == model.ConfigCoverAll && !singleton.Conf.IgnoredIPNotificationServerIDs[clientID]) ||
|
||||
(singleton.Conf.Cover == model.ConfigCoverIgnoreAll && singleton.Conf.IgnoredIPNotificationServerIDs[clientID])) &&
|
||||
|
||||
302
service/singleton/ddns.go
Normal file
302
service/singleton/ddns.go
Normal file
@@ -0,0 +1,302 @@
|
||||
package singleton
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type DDNSDomainConfig struct {
|
||||
EnableIPv4 bool
|
||||
EnableIpv6 bool
|
||||
FullDomain string
|
||||
Ipv4Addr string
|
||||
Ipv6Addr string
|
||||
}
|
||||
|
||||
type DDNSProvider interface {
|
||||
// UpdateDomain Return is updated
|
||||
UpdateDomain(domainConfig *DDNSDomainConfig) bool
|
||||
}
|
||||
|
||||
type DDNSProviderWebHook struct {
|
||||
URL string
|
||||
RequestMethod string
|
||||
RequestBody string
|
||||
RequestHeader string
|
||||
}
|
||||
|
||||
func (provider DDNSProviderWebHook) UpdateDomain(domainConfig *DDNSDomainConfig) bool {
|
||||
if domainConfig == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if domainConfig.FullDomain == "" {
|
||||
log.Println("NEZHA>> Failed to update an empty domain")
|
||||
return false
|
||||
}
|
||||
updated := false
|
||||
client := &http.Client{}
|
||||
if domainConfig.EnableIPv4 && domainConfig.Ipv4Addr != "" {
|
||||
url := provider.FormatWebhookString(provider.URL, domainConfig, "ipv4")
|
||||
body := provider.FormatWebhookString(provider.RequestBody, domainConfig, "ipv4")
|
||||
header := provider.FormatWebhookString(provider.RequestHeader, domainConfig, "ipv4")
|
||||
headers := strings.Split(header, "\n")
|
||||
req, err := http.NewRequest(provider.RequestMethod, url, bytes.NewBufferString(body))
|
||||
if err == nil && req != nil {
|
||||
SetStringHeadersToRequest(req, headers)
|
||||
if _, err := client.Do(req); err != nil {
|
||||
log.Printf("NEZHA>> Failed to update a domain: %s. Cause by: %s\n", domainConfig.FullDomain, err.Error())
|
||||
}
|
||||
updated = true
|
||||
}
|
||||
}
|
||||
if domainConfig.EnableIpv6 && domainConfig.Ipv6Addr != "" {
|
||||
url := provider.FormatWebhookString(provider.URL, domainConfig, "ipv6")
|
||||
body := provider.FormatWebhookString(provider.RequestBody, domainConfig, "ipv6")
|
||||
header := provider.FormatWebhookString(provider.RequestHeader, domainConfig, "ipv6")
|
||||
headers := strings.Split(header, "\n")
|
||||
req, err := http.NewRequest(provider.RequestMethod, url, bytes.NewBufferString(body))
|
||||
if err == nil && req != nil {
|
||||
SetStringHeadersToRequest(req, headers)
|
||||
if _, err := client.Do(req); err != nil {
|
||||
log.Printf("NEZHA>> Failed to update a domain: %s. Cause by: %s\n", domainConfig.FullDomain, err.Error())
|
||||
}
|
||||
updated = true
|
||||
}
|
||||
}
|
||||
return updated
|
||||
}
|
||||
|
||||
type DDNSProviderDummy struct{}
|
||||
|
||||
func (provider DDNSProviderDummy) UpdateDomain(domainConfig *DDNSDomainConfig) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
type DDNSProviderCloudflare struct {
|
||||
Secret string
|
||||
}
|
||||
|
||||
func (provider DDNSProviderCloudflare) UpdateDomain(domainConfig *DDNSDomainConfig) bool {
|
||||
if domainConfig == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
zoneID, err := provider.getZoneID(domainConfig.FullDomain)
|
||||
if err != nil {
|
||||
log.Printf("无法获取 zone ID: %s\n", err)
|
||||
return false
|
||||
}
|
||||
|
||||
record, err := provider.findDNSRecord(zoneID, domainConfig.FullDomain)
|
||||
if err != nil {
|
||||
log.Printf("查找 DNS 记录时出错: %s\n", err)
|
||||
return false
|
||||
}
|
||||
|
||||
if record == nil {
|
||||
// 添加 DNS 记录
|
||||
return provider.createDNSRecord(zoneID, domainConfig)
|
||||
} else {
|
||||
// 更新 DNS 记录
|
||||
return provider.updateDNSRecord(zoneID, record["id"].(string), domainConfig)
|
||||
}
|
||||
}
|
||||
|
||||
func (provider DDNSProviderCloudflare) getZoneID(domain string) (string, error) {
|
||||
_, realDomain := SplitDomain(domain)
|
||||
url := fmt.Sprintf("https://api.cloudflare.com/client/v4/zones?name=%s", realDomain)
|
||||
body, err := provider.sendRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var res map[string]interface{}
|
||||
err = json.Unmarshal(body, &res)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
result := res["result"].([]interface{})
|
||||
if len(result) > 0 {
|
||||
zoneID := result[0].(map[string]interface{})["id"].(string)
|
||||
return zoneID, nil
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("找不到 Zone ID")
|
||||
}
|
||||
|
||||
func (provider DDNSProviderCloudflare) findDNSRecord(zoneID string, domain string) (map[string]interface{}, error) {
|
||||
url := fmt.Sprintf("https://api.cloudflare.com/client/v4/zones/%s/dns_records?type=A&name=%s", zoneID, domain)
|
||||
body, err := provider.sendRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var res map[string]interface{}
|
||||
err = json.Unmarshal(body, &res)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := res["result"].([]interface{})
|
||||
if len(result) > 0 {
|
||||
return result[0].(map[string]interface{}), nil
|
||||
}
|
||||
|
||||
return nil, nil // 没有找到 DNS 记录
|
||||
}
|
||||
|
||||
func (provider DDNSProviderCloudflare) createDNSRecord(zoneID string, domainConfig *DDNSDomainConfig) bool {
|
||||
url := fmt.Sprintf("https://api.cloudflare.com/client/v4/zones/%s/dns_records", zoneID)
|
||||
data := map[string]interface{}{
|
||||
"type": "A",
|
||||
"name": domainConfig.FullDomain,
|
||||
"content": domainConfig.Ipv4Addr,
|
||||
"ttl": 3600,
|
||||
"proxied": false,
|
||||
}
|
||||
jsonData, _ := json.Marshal(data)
|
||||
_, err := provider.sendRequest("POST", url, jsonData)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (provider DDNSProviderCloudflare) updateDNSRecord(zoneID string, recordID string, domainConfig *DDNSDomainConfig) bool {
|
||||
url := fmt.Sprintf("https://api.cloudflare.com/client/v4/zones/%s/dns_records/%s", zoneID, recordID)
|
||||
data := map[string]interface{}{
|
||||
"type": "A",
|
||||
"name": domainConfig.FullDomain,
|
||||
"content": domainConfig.Ipv4Addr,
|
||||
"ttl": 3600,
|
||||
"proxied": false,
|
||||
}
|
||||
jsonData, _ := json.Marshal(data)
|
||||
_, err := provider.sendRequest("PATCH", url, jsonData)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// 以下为辅助方法,如发送 HTTP 请求等
|
||||
func (provider DDNSProviderCloudflare) sendRequest(method string, url string, data []byte) ([]byte, error) {
|
||||
client := &http.Client{}
|
||||
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 := client.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
|
||||
}
|
||||
|
||||
func (provider DDNSProviderWebHook) FormatWebhookString(s string, config *DDNSDomainConfig, ipType string) string {
|
||||
if config == nil {
|
||||
return s
|
||||
}
|
||||
|
||||
result := strings.TrimSpace(s)
|
||||
result = strings.Replace(s, "{ip}", config.Ipv4Addr, -1)
|
||||
result = strings.Replace(result, "{domain}", config.FullDomain, -1)
|
||||
result = strings.Replace(result, "{type}", ipType, -1)
|
||||
result = strings.Replace(result, "{access_id}", Conf.DDNS.AccessID, -1)
|
||||
result = strings.Replace(result, "{access_secret}", Conf.DDNS.AccessSecret, -1)
|
||||
// remove \r
|
||||
result = strings.Replace(result, "\r", "", -1)
|
||||
return result
|
||||
}
|
||||
|
||||
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])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SplitDomain 分割域名为前缀和一级域名
|
||||
func SplitDomain(domain string) (prefix string, topLevelDomain string) {
|
||||
// 带有二级TLD的一些常见例子,需要特别处理
|
||||
secondLevelTLDs := map[string]bool{
|
||||
".co.uk": true, ".com.cn": true, ".gov.cn": true, ".net.cn": true, ".org.cn": true,
|
||||
}
|
||||
|
||||
// 分割域名为"."的各部分
|
||||
parts := strings.Split(domain, ".")
|
||||
|
||||
// 处理特殊情况,例如 ".co.uk"
|
||||
for i := len(parts) - 2; i > 0; i-- {
|
||||
potentialTLD := fmt.Sprintf(".%s.%s", parts[i], parts[i+1])
|
||||
if secondLevelTLDs[potentialTLD] {
|
||||
if i > 1 {
|
||||
return strings.Join(parts[:i-1], "."), strings.Join(parts[i-1:], ".")
|
||||
}
|
||||
return "", domain // 当域名仅为二级TLD时,无前缀
|
||||
}
|
||||
}
|
||||
|
||||
// 常规处理,查找最后一个"."前的所有内容作为前缀
|
||||
if len(parts) > 2 {
|
||||
return strings.Join(parts[:len(parts)-2], "."), strings.Join(parts[len(parts)-2:], ".")
|
||||
}
|
||||
return "", domain // 当域名不包含子域名时,无前缀
|
||||
}
|
||||
|
||||
func RetryableUpdateDomain(provider DDNSProvider, config *DDNSDomainConfig, maxRetries int) bool {
|
||||
if nil == config {
|
||||
return false
|
||||
}
|
||||
for retries := 0; retries < maxRetries; retries++ {
|
||||
log.Printf("NEZHA>> 正在尝试更新域名(%s)DDNS(%d/%d)\n", config.FullDomain, retries+1, maxRetries)
|
||||
if provider.UpdateDomain(config) {
|
||||
log.Printf("NEZHA>> 尝试更新域名(%s)DDNS成功\n", config.FullDomain)
|
||||
return true
|
||||
}
|
||||
}
|
||||
log.Printf("NEZHA>> 尝试更新域名(%s)DDNS失败\n", config.FullDomain)
|
||||
return false
|
||||
}
|
||||
|
||||
func GetDDNSProviderFromString(provider string) (DDNSProvider, error) {
|
||||
switch provider {
|
||||
case "webhook":
|
||||
return DDNSProviderWebHook{
|
||||
URL: Conf.DDNS.WebhookURL,
|
||||
RequestMethod: Conf.DDNS.WebhookMethod,
|
||||
RequestBody: Conf.DDNS.WebhookRequestBody,
|
||||
RequestHeader: Conf.DDNS.WebhookHeaders,
|
||||
}, nil
|
||||
case "dummy":
|
||||
return DDNSProviderDummy{}, nil
|
||||
case "cloudflare":
|
||||
return DDNSProviderCloudflare{
|
||||
Secret: Conf.DDNS.AccessSecret,
|
||||
}, nil
|
||||
}
|
||||
return DDNSProviderDummy{}, errors.New(fmt.Sprintf("无法找到配置的DDNS提供者%s", Conf.DDNS.Provider))
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package singleton
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
@@ -46,6 +47,21 @@ func InitConfigFromPath(path string) {
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ValidateConfig()
|
||||
}
|
||||
|
||||
// ValidateConfig 验证配置文件有效性
|
||||
func ValidateConfig() {
|
||||
// 如果DDNS启用则检查Provider是否存在, 不存在直接退出
|
||||
if Conf.DDNS.Enable {
|
||||
_, err := GetDDNSProviderFromString(Conf.DDNS.Provider)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if Conf.DDNS.MaxRetries < 1 || Conf.DDNS.MaxRetries > 10 {
|
||||
panic(fmt.Errorf("DDNS.MaxRetries值域为[1, 10]的整数, 当前为 %d", Conf.DDNS.MaxRetries))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// InitDBFromPath 从给出的文件路径中加载数据库
|
||||
|
||||
Reference in New Issue
Block a user