chore: 初始提交 Fonu 1.0.2
- 公网反代:Nginx 状态卡片(版本 / 运行状态 / 配置校验 / 端口监听) - 部署设置第一、二梯队(HTTPS 开关、安全头、限流、连接数、仅中国大陆 IP) - 隧道检测兼容 frps v0.68 / v0.71(V2 与旧版端点) - 恢复被清空的 internal/frp/store.go、internal/api/ddns_handler.go、web/src/views/DdnsView.vue
This commit is contained in:
@@ -0,0 +1,244 @@
|
||||
package ddns
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha1"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const alidnsEndpoint = "https://alidns.aliyuncs.com/"
|
||||
|
||||
type AliDNS struct {
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func NewAliDNS() *AliDNS {
|
||||
return &AliDNS{client: &http.Client{Timeout: 15 * time.Second}}
|
||||
}
|
||||
|
||||
func (a *AliDNS) Name() string { return "alidns" }
|
||||
|
||||
func (a *AliDNS) Verify(ctx context.Context, cred Credentials) error {
|
||||
_, err := a.request(ctx, cred, map[string]string{
|
||||
"Action": "DescribeDomains",
|
||||
"PageNumber": "1",
|
||||
"PageSize": "1",
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *AliDNS) HasZone(ctx context.Context, cred Credentials, zone string) (bool, error) {
|
||||
body, err := a.request(ctx, cred, map[string]string{
|
||||
"Action": "DescribeDomains",
|
||||
"KeyWord": zone,
|
||||
"PageNumber": "1",
|
||||
"PageSize": "50",
|
||||
})
|
||||
if err != nil {
|
||||
ok, lookupErr := zoneLookupOK(err)
|
||||
return ok, lookupErr
|
||||
}
|
||||
var resp alidnsDomainsResponse
|
||||
if err := json.Unmarshal(body, &resp); err != nil {
|
||||
return false, err
|
||||
}
|
||||
zone = strings.ToLower(strings.TrimSpace(zone))
|
||||
for _, item := range resp.Domains.Domain {
|
||||
if strings.EqualFold(item.DomainName, zone) {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (a *AliDNS) GetRecordIP(ctx context.Context, cred Credentials, rootDomain, recordName, recordType string) (string, error) {
|
||||
subDomain := aliRR(recordName)
|
||||
body, err := a.request(ctx, cred, map[string]string{
|
||||
"Action": "DescribeDomainRecords",
|
||||
"DomainName": rootDomain,
|
||||
"RRKeyWord": subDomain,
|
||||
"Type": recordType,
|
||||
"PageNumber": "1",
|
||||
"PageSize": "50",
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var resp alidnsRecordsResponse
|
||||
if err := json.Unmarshal(body, &resp); err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, rec := range resp.DomainRecords.Record {
|
||||
if strings.EqualFold(rec.Type, recordType) && rec.RR == subDomain {
|
||||
return rec.Value, nil
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (a *AliDNS) UpdateRecord(ctx context.Context, cred Credentials, rootDomain, recordName, recordType, ip string) error {
|
||||
subDomain := aliRR(recordName)
|
||||
existingID, err := a.findRecordID(ctx, cred, rootDomain, subDomain, recordType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if existingID == "" {
|
||||
_, err = a.request(ctx, cred, map[string]string{
|
||||
"Action": "AddDomainRecord",
|
||||
"DomainName": rootDomain,
|
||||
"RR": subDomain,
|
||||
"Type": recordType,
|
||||
"Value": ip,
|
||||
"TTL": "600",
|
||||
})
|
||||
return err
|
||||
}
|
||||
_, err = a.request(ctx, cred, map[string]string{
|
||||
"Action": "UpdateDomainRecord",
|
||||
"RecordId": existingID,
|
||||
"RR": subDomain,
|
||||
"Type": recordType,
|
||||
"Value": ip,
|
||||
"TTL": "600",
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *AliDNS) findRecordID(ctx context.Context, cred Credentials, rootDomain, subDomain, recordType string) (string, error) {
|
||||
body, err := a.request(ctx, cred, map[string]string{
|
||||
"Action": "DescribeDomainRecords",
|
||||
"DomainName": rootDomain,
|
||||
"RRKeyWord": subDomain,
|
||||
"Type": recordType,
|
||||
"PageNumber": "1",
|
||||
"PageSize": "50",
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var resp alidnsRecordsResponse
|
||||
if err := json.Unmarshal(body, &resp); err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, rec := range resp.DomainRecords.Record {
|
||||
if strings.EqualFold(rec.Type, recordType) && rec.RR == subDomain {
|
||||
return rec.RecordID, nil
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func aliRR(recordName string) string {
|
||||
subDomain := subDomainForRecord(recordName)
|
||||
if subDomain == "@" {
|
||||
return ""
|
||||
}
|
||||
return subDomain
|
||||
}
|
||||
|
||||
func (a *AliDNS) request(ctx context.Context, cred Credentials, actionParams map[string]string) ([]byte, error) {
|
||||
if cred.Token == "" || cred.Secret == "" {
|
||||
return nil, fmt.Errorf("阿里云 DNS 凭证未配置")
|
||||
}
|
||||
query := map[string]string{
|
||||
"Format": "JSON",
|
||||
"Version": "2015-01-09",
|
||||
"AccessKeyId": cred.Token,
|
||||
"SignatureMethod": "HMAC-SHA1",
|
||||
"Timestamp": time.Now().UTC().Format("2006-01-02T15:04:05Z"),
|
||||
"SignatureVersion": "1.0",
|
||||
"SignatureNonce": fmt.Sprintf("%d", time.Now().UnixNano()),
|
||||
}
|
||||
for k, v := range actionParams {
|
||||
query[k] = v
|
||||
}
|
||||
query["Signature"] = signAliyun(query, cred.Secret)
|
||||
|
||||
values := url.Values{}
|
||||
for k, v := range query {
|
||||
values.Set(k, v)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, alidnsEndpoint+"?"+values.Encode(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := a.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var envelope alidnsEnvelope
|
||||
if err := json.Unmarshal(body, &envelope); err == nil && envelope.Code != "" {
|
||||
return nil, fmt.Errorf("阿里云 DNS API 错误:%s", envelope.Message)
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func signAliyun(params map[string]string, secret string) string {
|
||||
keys := make([]string, 0, len(params))
|
||||
for k := range params {
|
||||
if k == "Signature" {
|
||||
continue
|
||||
}
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
var canonical strings.Builder
|
||||
for i, k := range keys {
|
||||
if i > 0 {
|
||||
canonical.WriteByte('&')
|
||||
}
|
||||
canonical.WriteString(percentEncode(k))
|
||||
canonical.WriteByte('=')
|
||||
canonical.WriteString(percentEncode(params[k]))
|
||||
}
|
||||
stringToSign := "GET&%2F&" + percentEncode(canonical.String())
|
||||
mac := hmac.New(sha1.New, []byte(secret+"&"))
|
||||
mac.Write([]byte(stringToSign))
|
||||
return base64.StdEncoding.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
func percentEncode(s string) string {
|
||||
encoded := url.QueryEscape(s)
|
||||
encoded = strings.ReplaceAll(encoded, "+", "%20")
|
||||
encoded = strings.ReplaceAll(encoded, "*", "%2A")
|
||||
encoded = strings.ReplaceAll(encoded, "%7E", "~")
|
||||
return encoded
|
||||
}
|
||||
|
||||
type alidnsEnvelope struct {
|
||||
Code string `json:"Code"`
|
||||
Message string `json:"Message"`
|
||||
}
|
||||
|
||||
type alidnsRecordsResponse struct {
|
||||
DomainRecords struct {
|
||||
Record []struct {
|
||||
RecordID string `json:"RecordId"`
|
||||
RR string `json:"RR"`
|
||||
Type string `json:"Type"`
|
||||
Value string `json:"Value"`
|
||||
} `json:"Record"`
|
||||
} `json:"DomainRecords"`
|
||||
}
|
||||
|
||||
type alidnsDomainsResponse struct {
|
||||
Domains struct {
|
||||
Domain []struct {
|
||||
DomainName string `json:"DomainName"`
|
||||
} `json:"Domain"`
|
||||
} `json:"Domains"`
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package ddns
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Cloudflare struct {
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func NewCloudflare() *Cloudflare {
|
||||
return &Cloudflare{client: &http.Client{Timeout: 15 * time.Second}}
|
||||
}
|
||||
|
||||
func (c *Cloudflare) Name() string { return "cloudflare" }
|
||||
|
||||
func (c *Cloudflare) Verify(ctx context.Context, cred Credentials) error {
|
||||
_, err := c.request(ctx, cred.Token, http.MethodGet, "https://api.cloudflare.com/client/v4/user/tokens/verify", nil)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *Cloudflare) HasZone(ctx context.Context, cred Credentials, zone string) (bool, error) {
|
||||
_, err := c.zoneID(ctx, cred.Token, zone)
|
||||
return zoneLookupOK(err)
|
||||
}
|
||||
|
||||
func (c *Cloudflare) GetRecordIP(ctx context.Context, cred Credentials, rootDomain, recordName, recordType string) (string, error) {
|
||||
zoneID, err := c.zoneID(ctx, cred.Token, rootDomain)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
fqdn := fqdnForRecord(rootDomain, recordName)
|
||||
body, err := c.request(ctx, cred.Token, http.MethodGet, fmt.Sprintf("https://api.cloudflare.com/client/v4/zones/%s/dns_records?type=%s&name=%s", zoneID, recordType, fqdn), nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var resp cfRecordsResponse
|
||||
if err := json.Unmarshal(body, &resp); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(resp.Result) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
return resp.Result[0].Content, nil
|
||||
}
|
||||
|
||||
func (c *Cloudflare) UpdateRecord(ctx context.Context, cred Credentials, rootDomain, recordName, recordType, ip string) error {
|
||||
zoneID, err := c.zoneID(ctx, cred.Token, rootDomain)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fqdn := fqdnForRecord(rootDomain, recordName)
|
||||
existingID, _, err := c.findRecord(ctx, cred.Token, zoneID, recordType, fqdn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload := map[string]any{
|
||||
"type": recordType,
|
||||
"name": fqdn,
|
||||
"content": ip,
|
||||
"ttl": 120,
|
||||
"proxied": false,
|
||||
}
|
||||
if existingID == "" {
|
||||
_, err = c.request(ctx, cred.Token, http.MethodPost, fmt.Sprintf("https://api.cloudflare.com/client/v4/zones/%s/dns_records", zoneID), payload)
|
||||
return err
|
||||
}
|
||||
_, err = c.request(ctx, cred.Token, http.MethodPut, fmt.Sprintf("https://api.cloudflare.com/client/v4/zones/%s/dns_records/%s", zoneID, existingID), payload)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *Cloudflare) zoneID(ctx context.Context, token, rootDomain string) (string, error) {
|
||||
body, err := c.request(ctx, token, http.MethodGet, fmt.Sprintf("https://api.cloudflare.com/client/v4/zones?name=%s", rootDomain), nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var resp cfZonesResponse
|
||||
if err := json.Unmarshal(body, &resp); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(resp.Result) == 0 {
|
||||
return "", fmt.Errorf("未找到域名 %s 对应的 Cloudflare Zone", rootDomain)
|
||||
}
|
||||
return resp.Result[0].ID, nil
|
||||
}
|
||||
|
||||
func (c *Cloudflare) findRecord(ctx context.Context, token, zoneID, recordType, fqdn string) (string, string, error) {
|
||||
body, err := c.request(ctx, token, http.MethodGet, fmt.Sprintf("https://api.cloudflare.com/client/v4/zones/%s/dns_records?type=%s&name=%s", zoneID, recordType, fqdn), nil)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
var resp cfRecordsResponse
|
||||
if err := json.Unmarshal(body, &resp); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if len(resp.Result) == 0 {
|
||||
return "", "", nil
|
||||
}
|
||||
return resp.Result[0].ID, resp.Result[0].Content, nil
|
||||
}
|
||||
|
||||
func (c *Cloudflare) request(ctx context.Context, token, method, url string, payload any) ([]byte, error) {
|
||||
var body io.Reader
|
||||
if payload != nil {
|
||||
raw, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body = bytes.NewReader(raw)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, url, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var envelope cfEnvelope
|
||||
if err := json.Unmarshal(raw, &envelope); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !envelope.Success {
|
||||
if len(envelope.Errors) > 0 {
|
||||
return nil, fmt.Errorf("Cloudflare API 错误:%s", envelope.Errors[0].Message)
|
||||
}
|
||||
return nil, fmt.Errorf("Cloudflare API 请求失败")
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
type cfEnvelope struct {
|
||||
Success bool `json:"success"`
|
||||
Errors []struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"errors"`
|
||||
}
|
||||
|
||||
type cfZonesResponse struct {
|
||||
Result []struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
} `json:"result"`
|
||||
}
|
||||
|
||||
type cfRecordsResponse struct {
|
||||
Result []struct {
|
||||
ID string `json:"id"`
|
||||
Content string `json:"content"`
|
||||
} `json:"result"`
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package ddns
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Credentials struct {
|
||||
Provider string `json:"provider,omitempty"`
|
||||
Token string `json:"token,omitempty"`
|
||||
TokenID string `json:"token_id,omitempty"`
|
||||
Secret string `json:"secret,omitempty"`
|
||||
}
|
||||
|
||||
func CredentialsFromSave(in SaveInput) Credentials {
|
||||
provider := strings.TrimSpace(in.Provider)
|
||||
if provider == "" {
|
||||
provider = "cloudflare"
|
||||
}
|
||||
switch provider {
|
||||
case "dnspod":
|
||||
return Credentials{Provider: "dnspod", TokenID: strings.TrimSpace(in.APITokenID), Token: strings.TrimSpace(in.APIToken)}
|
||||
case "alidns":
|
||||
return Credentials{Provider: "alidns", Token: strings.TrimSpace(in.APIToken), Secret: strings.TrimSpace(in.APISecret)}
|
||||
case "tencentcloud":
|
||||
return Credentials{Provider: "tencentcloud", Token: strings.TrimSpace(in.APIToken), Secret: strings.TrimSpace(in.APISecret)}
|
||||
case "volcengine":
|
||||
return Credentials{Provider: "volcengine", Token: strings.TrimSpace(in.APIToken), Secret: strings.TrimSpace(in.APISecret)}
|
||||
default:
|
||||
return Credentials{Provider: "cloudflare", Token: strings.TrimSpace(in.APIToken)}
|
||||
}
|
||||
}
|
||||
|
||||
func (c Credentials) HasValues() bool {
|
||||
switch c.Provider {
|
||||
case "dnspod":
|
||||
return c.TokenID != "" && c.Token != ""
|
||||
case "alidns", "tencentcloud", "volcengine":
|
||||
return c.Token != "" && c.Secret != ""
|
||||
default:
|
||||
return c.Token != ""
|
||||
}
|
||||
}
|
||||
|
||||
func (c Credentials) Validate(provider string) error {
|
||||
switch provider {
|
||||
case "dnspod":
|
||||
if c.TokenID == "" || c.Token == "" {
|
||||
return fmt.Errorf("请填写 DNSPod ID 和 Token")
|
||||
}
|
||||
case "alidns":
|
||||
if c.Token == "" || c.Secret == "" {
|
||||
return fmt.Errorf("请填写阿里云 AccessKey ID 和 Secret")
|
||||
}
|
||||
case "tencentcloud":
|
||||
if c.Token == "" || c.Secret == "" {
|
||||
return fmt.Errorf("请填写腾讯云 SecretId 和 SecretKey")
|
||||
}
|
||||
case "volcengine":
|
||||
if c.Token == "" || c.Secret == "" {
|
||||
return fmt.Errorf("请填写火山引擎 AccessKey ID 和 Secret Access Key")
|
||||
}
|
||||
default:
|
||||
if c.Token == "" {
|
||||
return fmt.Errorf("请填写 API Token")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c Credentials) LoginToken() string {
|
||||
return c.TokenID + "," + c.Token
|
||||
}
|
||||
|
||||
func (c Credentials) Marshal() ([]byte, error) {
|
||||
return json.Marshal(c)
|
||||
}
|
||||
|
||||
func ParseCredentials(raw string) (Credentials, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return Credentials{}, fmt.Errorf("凭证未配置")
|
||||
}
|
||||
if strings.HasPrefix(raw, "{") {
|
||||
var cred Credentials
|
||||
if err := json.Unmarshal([]byte(raw), &cred); err != nil {
|
||||
return Credentials{}, err
|
||||
}
|
||||
if cred.Provider == "" {
|
||||
cred.Provider = "cloudflare"
|
||||
}
|
||||
return cred, nil
|
||||
}
|
||||
return Credentials{Provider: "cloudflare", Token: raw}, nil
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package ddns
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseCredentialsLegacyToken(t *testing.T) {
|
||||
cred, err := ParseCredentials("cf-token")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cred.Provider != "cloudflare" || cred.Token != "cf-token" {
|
||||
t.Fatalf("unexpected cred: %+v", cred)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialsLoginToken(t *testing.T) {
|
||||
cred := Credentials{TokenID: "123", Token: "abc"}
|
||||
if cred.LoginToken() != "123,abc" {
|
||||
t.Fatal("login token mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFqdnForRecord(t *testing.T) {
|
||||
if fqdnForRecord("example.com", "*") != "*.example.com" {
|
||||
t.Fatal("wildcard fqdn mismatch")
|
||||
}
|
||||
if fqdnForRecord("example.com", "@") != "example.com" {
|
||||
t.Fatal("apex fqdn mismatch")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package ddns
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/nrdcg/dnspod-go"
|
||||
)
|
||||
|
||||
type DNSPod struct {
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func NewDNSPod() *DNSPod {
|
||||
return &DNSPod{client: &http.Client{Timeout: 15 * time.Second}}
|
||||
}
|
||||
|
||||
func (d *DNSPod) Name() string { return "dnspod" }
|
||||
|
||||
func (d *DNSPod) Verify(ctx context.Context, cred Credentials) error {
|
||||
client := d.apiClient(cred)
|
||||
_, _, err := client.Domains.List()
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DNSPod) HasZone(ctx context.Context, cred Credentials, zone string) (bool, error) {
|
||||
_, err := d.domainID(d.apiClient(cred), zone)
|
||||
return zoneLookupOK(err)
|
||||
}
|
||||
|
||||
func (d *DNSPod) GetRecordIP(ctx context.Context, cred Credentials, rootDomain, recordName, recordType string) (string, error) {
|
||||
client := d.apiClient(cred)
|
||||
domainID, err := d.domainID(client, rootDomain)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
subDomain := subDomainForRecord(recordName)
|
||||
records, _, err := client.Records.List(domainID, subDomain)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, rec := range records {
|
||||
if strings.EqualFold(rec.Type, recordType) {
|
||||
return rec.Value, nil
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (d *DNSPod) UpdateRecord(ctx context.Context, cred Credentials, rootDomain, recordName, recordType, ip string) error {
|
||||
client := d.apiClient(cred)
|
||||
domainID, err := d.domainID(client, rootDomain)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
subDomain := subDomainForRecord(recordName)
|
||||
records, _, err := client.Records.List(domainID, subDomain)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, rec := range records {
|
||||
if strings.EqualFold(rec.Type, recordType) {
|
||||
_, _, err := client.Records.Update(domainID, rec.ID, dnspod.Record{
|
||||
Type: recordType,
|
||||
Name: subDomain,
|
||||
Value: ip,
|
||||
Line: rec.Line,
|
||||
TTL: rec.TTL,
|
||||
})
|
||||
return err
|
||||
}
|
||||
}
|
||||
_, _, err = client.Records.Create(domainID, dnspod.Record{
|
||||
Type: recordType,
|
||||
Name: subDomain,
|
||||
Value: ip,
|
||||
Line: "默认",
|
||||
TTL: strconv.Itoa(120),
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DNSPod) domainID(client *dnspod.Client, rootDomain string) (string, error) {
|
||||
domains, _, err := client.Domains.List()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, domain := range domains {
|
||||
if domain.Name == rootDomain {
|
||||
return domain.ID.String(), nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("未找到域名 %s 对应的 DNSPod 域名", rootDomain)
|
||||
}
|
||||
|
||||
func (d *DNSPod) apiClient(cred Credentials) *dnspod.Client {
|
||||
params := dnspod.CommonParams{LoginToken: cred.LoginToken(), Format: "json"}
|
||||
client := dnspod.NewClient(params)
|
||||
client.HTTPClient = d.client
|
||||
return client
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package ddns
|
||||
|
||||
import "strings"
|
||||
|
||||
func fqdnForRecord(rootDomain, recordName string) string {
|
||||
recordName = strings.TrimSpace(recordName)
|
||||
if recordName == "" || recordName == "@" {
|
||||
return rootDomain
|
||||
}
|
||||
if strings.Contains(recordName, ".") {
|
||||
return recordName
|
||||
}
|
||||
if recordName == "*" {
|
||||
return "*." + rootDomain
|
||||
}
|
||||
return recordName + "." + rootDomain
|
||||
}
|
||||
|
||||
func subDomainForRecord(recordName string) string {
|
||||
recordName = strings.TrimSpace(recordName)
|
||||
if recordName == "" || recordName == "@" {
|
||||
return "@"
|
||||
}
|
||||
return recordName
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package ddns
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type DomainRecord struct {
|
||||
Domain string `json:"domain"`
|
||||
IPv4 string `json:"ipv4,omitempty"`
|
||||
IPv6 string `json:"ipv6,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
func EncodeDomainRecords(records []DomainRecord) string {
|
||||
if len(records) == 0 {
|
||||
return "[]"
|
||||
}
|
||||
b, err := json.Marshal(records)
|
||||
if err != nil {
|
||||
return "[]"
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func DecodeDomainRecords(raw string) []DomainRecord {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" || raw == "[]" {
|
||||
return nil
|
||||
}
|
||||
var records []DomainRecord
|
||||
if err := json.Unmarshal([]byte(raw), &records); err != nil {
|
||||
return nil
|
||||
}
|
||||
return records
|
||||
}
|
||||
|
||||
func BuildDomainRecords(cfg Config) []DomainRecord {
|
||||
stored := cfg.DomainRecords
|
||||
byDomain := make(map[string]DomainRecord, len(stored))
|
||||
for _, rec := range stored {
|
||||
byDomain[strings.ToLower(rec.Domain)] = rec
|
||||
}
|
||||
|
||||
lines := cfg.DomainLines()
|
||||
out := make([]DomainRecord, 0, len(lines))
|
||||
for _, domain := range lines {
|
||||
key := strings.ToLower(domain)
|
||||
if rec, ok := byDomain[key]; ok {
|
||||
rec.Domain = domain
|
||||
out = append(out, rec)
|
||||
continue
|
||||
}
|
||||
rec := DomainRecord{Domain: domain, Status: "unknown"}
|
||||
if cfg.LastStatus != "" && cfg.LastStatus != "unknown" {
|
||||
rec.Status = cfg.LastStatus
|
||||
rec.Message = cfg.LastError
|
||||
}
|
||||
out = append(out, rec)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package ddns
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestManagedDNSZonesMultiRoot(t *testing.T) {
|
||||
cfg := Config{
|
||||
RootDomain: "roven.cc",
|
||||
RecordNames: []string{"roven.cc", "chiak.cc"},
|
||||
}
|
||||
zones := cfg.ManagedDNSZones()
|
||||
if len(zones) != 2 {
|
||||
t.Fatalf("expected 2 zones, got %v", zones)
|
||||
}
|
||||
if !cfg.CoversDomain("chiak.cc") || !cfg.CoversDomain("*.chiak.cc") {
|
||||
t.Fatal("expected chiak.cc coverage")
|
||||
}
|
||||
if cfg.CoversDomain("other.com") {
|
||||
t.Fatal("unexpected coverage")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package ddns
|
||||
|
||||
import "context"
|
||||
|
||||
type Provider interface {
|
||||
Name() string
|
||||
Verify(ctx context.Context, cred Credentials) error
|
||||
HasZone(ctx context.Context, cred Credentials, zone string) (bool, error)
|
||||
UpdateRecord(ctx context.Context, cred Credentials, rootDomain, recordName, recordType, ip string) error
|
||||
GetRecordIP(ctx context.Context, cred Credentials, rootDomain, recordName, recordType string) (string, error)
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
package ddns
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func ValidateDomainFormat(domain string) error {
|
||||
domain = strings.TrimSpace(strings.ToLower(strings.TrimSuffix(domain, ".")))
|
||||
if domain == "" {
|
||||
return fmt.Errorf("域名不能为空")
|
||||
}
|
||||
if strings.HasPrefix(domain, "*.") {
|
||||
domain = strings.TrimPrefix(domain, "*.")
|
||||
}
|
||||
parts := strings.Split(domain, ".")
|
||||
if len(parts) < 2 {
|
||||
return fmt.Errorf("请填写完整域名,例如 s.example.com")
|
||||
}
|
||||
for _, part := range parts {
|
||||
if part == "" {
|
||||
return fmt.Errorf("域名格式无效")
|
||||
}
|
||||
if len(part) > 63 {
|
||||
return fmt.Errorf("域名标签过长")
|
||||
}
|
||||
for i := 0; i < len(part); i++ {
|
||||
c := part[i]
|
||||
if (c < 'a' || c > 'z') && (c < '0' || c > '9') && c != '-' {
|
||||
return fmt.Errorf("域名包含非法字符")
|
||||
}
|
||||
}
|
||||
if part[0] == '-' || part[len(part)-1] == '-' {
|
||||
return fmt.Errorf("域名标签不能以连字符开头或结尾")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func FQDNsFromSaveInput(in SaveInput) ([]string, error) {
|
||||
var names []string
|
||||
var err error
|
||||
if len(in.Domains) > 0 {
|
||||
_, names, err = ParseDomainLines(in.Domains)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
root := strings.ToLower(strings.TrimSpace(in.RootDomain))
|
||||
if root == "" {
|
||||
return nil, fmt.Errorf("至少需要一个域名")
|
||||
}
|
||||
recordNames, err := ParseRecordNames(in.RecordNames, in.RecordName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
names = FormatDomainLines(root, recordNames)
|
||||
}
|
||||
for _, name := range names {
|
||||
if err := ValidateDomainFormat(name); err != nil {
|
||||
return nil, fmt.Errorf("%s:%s", name, err.Error())
|
||||
}
|
||||
}
|
||||
return names, nil
|
||||
}
|
||||
|
||||
func ParseDomainLines(lines []string) (rootDomain string, recordNames []string, err error) {
|
||||
seen := make(map[string]struct{})
|
||||
names := make([]string, 0, len(lines))
|
||||
for _, line := range lines {
|
||||
root, record, lineErr := parseDomainLine(line)
|
||||
if lineErr != nil {
|
||||
return "", nil, lineErr
|
||||
}
|
||||
if root == "" {
|
||||
continue
|
||||
}
|
||||
fqdn := FormatDomainLine(root, record)
|
||||
if _, ok := seen[fqdn]; ok {
|
||||
return "", nil, fmt.Errorf("域名 %s 重复", fqdn)
|
||||
}
|
||||
seen[fqdn] = struct{}{}
|
||||
if rootDomain == "" {
|
||||
rootDomain = root
|
||||
}
|
||||
names = append(names, fqdn)
|
||||
}
|
||||
if len(names) == 0 {
|
||||
return "", nil, fmt.Errorf("至少需要一个域名")
|
||||
}
|
||||
return rootDomain, names, nil
|
||||
}
|
||||
|
||||
func ResolveDomainTarget(configRoot, name string) (rootDomain, recordName string) {
|
||||
name = strings.TrimSpace(strings.ToLower(name))
|
||||
if strings.Contains(name, ".") {
|
||||
if root, record, err := parseDomainLine(name); err == nil {
|
||||
return root, record
|
||||
}
|
||||
}
|
||||
return strings.ToLower(strings.TrimSpace(configRoot)), name
|
||||
}
|
||||
|
||||
func parseDomainLine(line string) (rootDomain, recordName string, err error) {
|
||||
line = strings.TrimSpace(strings.ToLower(line))
|
||||
if line == "" {
|
||||
return "", "", nil
|
||||
}
|
||||
if idx := strings.Index(line, ":"); idx > 0 {
|
||||
host := line[:idx]
|
||||
if strings.Count(host, ":") <= 1 {
|
||||
line = host
|
||||
}
|
||||
}
|
||||
line = strings.TrimSuffix(line, ".")
|
||||
if strings.HasPrefix(line, "*.") {
|
||||
root := strings.TrimPrefix(line, "*.")
|
||||
if root == "" || !strings.Contains(root, ".") {
|
||||
return "", "", fmt.Errorf("域名格式无效: %s", line)
|
||||
}
|
||||
return root, "*", nil
|
||||
}
|
||||
parts := strings.Split(line, ".")
|
||||
if len(parts) < 2 {
|
||||
return "", "", fmt.Errorf("域名格式无效: %s", line)
|
||||
}
|
||||
rootDomain = parts[len(parts)-2] + "." + parts[len(parts)-1]
|
||||
if len(parts) == 2 {
|
||||
return rootDomain, "@", nil
|
||||
}
|
||||
recordName = strings.Join(parts[:len(parts)-2], ".")
|
||||
return rootDomain, recordName, nil
|
||||
}
|
||||
|
||||
func FormatDomainLine(rootDomain, recordName string) string {
|
||||
recordName = strings.TrimSpace(strings.ToLower(recordName))
|
||||
rootDomain = strings.TrimSpace(strings.ToLower(rootDomain))
|
||||
if strings.Contains(recordName, ".") {
|
||||
parts := strings.Split(recordName, ".")
|
||||
if len(parts) >= 3 {
|
||||
if root, record, err := parseDomainLine(recordName); err == nil {
|
||||
return joinDomainLine(root, record)
|
||||
}
|
||||
}
|
||||
if len(parts) == 2 {
|
||||
if root, record, err := parseDomainLine(recordName); err == nil && record == "@" && recordName == root {
|
||||
if rootDomain == "" || rootDomain == root {
|
||||
return recordName
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return joinDomainLine(rootDomain, recordName)
|
||||
}
|
||||
|
||||
// canonicalFQDN returns name when it is already a complete FQDN (e.g. api.nas.example.com).
|
||||
func canonicalFQDN(name string) string {
|
||||
name = strings.TrimSpace(strings.ToLower(strings.TrimSuffix(name, ".")))
|
||||
if name == "" || name == "@" || !strings.Contains(name, ".") {
|
||||
return ""
|
||||
}
|
||||
root, record, err := parseDomainLine(name)
|
||||
if err != nil {
|
||||
return name
|
||||
}
|
||||
fqdn := joinDomainLine(root, record)
|
||||
if fqdn == name {
|
||||
return name
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func joinDomainLine(rootDomain, recordName string) string {
|
||||
if recordName == "" || recordName == "@" {
|
||||
return rootDomain
|
||||
}
|
||||
if recordName == "*" {
|
||||
return "*." + rootDomain
|
||||
}
|
||||
return recordName + "." + rootDomain
|
||||
}
|
||||
|
||||
func FormatDomainLines(rootDomain string, recordNames []string) []string {
|
||||
out := make([]string, 0, len(recordNames))
|
||||
for _, name := range recordNames {
|
||||
if fqdn := canonicalFQDN(name); fqdn != "" {
|
||||
out = append(out, fqdn)
|
||||
continue
|
||||
}
|
||||
root, record := ResolveDomainTarget(rootDomain, name)
|
||||
out = append(out, FormatDomainLine(root, record))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func ParseRecordNames(recordNames []string, legacy string) ([]string, error) {
|
||||
raw := recordNames
|
||||
if len(raw) == 0 && strings.TrimSpace(legacy) != "" {
|
||||
raw = strings.Split(legacy, "\n")
|
||||
}
|
||||
if len(raw) == 0 {
|
||||
return nil, fmt.Errorf("至少需要一个子域名")
|
||||
}
|
||||
|
||||
seen := make(map[string]struct{})
|
||||
out := make([]string, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
name := normalizeRecordName(item)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[name]; ok {
|
||||
return nil, fmt.Errorf("子域名 %s 重复", name)
|
||||
}
|
||||
seen[name] = struct{}{}
|
||||
out = append(out, name)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, fmt.Errorf("至少需要一个子域名")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func normalizeRecordName(raw string) string {
|
||||
name := strings.TrimSpace(strings.ToLower(raw))
|
||||
name = strings.TrimSuffix(name, ".")
|
||||
if name == "" || name == "@" {
|
||||
return "@"
|
||||
}
|
||||
if strings.HasPrefix(name, "*.") {
|
||||
root := strings.TrimPrefix(name, "*.")
|
||||
if root != "" {
|
||||
return FormatDomainLine(root, "*")
|
||||
}
|
||||
return "*"
|
||||
}
|
||||
if strings.Contains(name, ".") {
|
||||
root, record, err := parseDomainLine(name)
|
||||
if err == nil {
|
||||
return FormatDomainLine(root, record)
|
||||
}
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func EncodeRecordNames(names []string) string {
|
||||
if len(names) == 0 {
|
||||
return "[]"
|
||||
}
|
||||
b, err := json.Marshal(names)
|
||||
if err != nil {
|
||||
return "[]"
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func DecodeRecordNames(raw string, fallback string) []string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw != "" && raw != "[]" {
|
||||
var names []string
|
||||
if err := json.Unmarshal([]byte(raw), &names); err == nil && len(names) > 0 {
|
||||
return names
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(fallback) != "" {
|
||||
return []string{normalizeRecordName(fallback)}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c Config) RecordNamesList() []string {
|
||||
if len(c.RecordNames) > 0 {
|
||||
return c.RecordNames
|
||||
}
|
||||
if names := DecodeRecordNames("", c.RecordName); len(names) > 0 {
|
||||
return names
|
||||
}
|
||||
return []string{"@"}
|
||||
}
|
||||
|
||||
func (c Config) DomainLines() []string {
|
||||
return FormatDomainLines(c.RootDomain, c.RecordNamesList())
|
||||
}
|
||||
|
||||
// ManagedDNSZones returns apex zones this DDNS credential can manage (for ACME DNS-01).
|
||||
func (c Config) ManagedDNSZones() []string {
|
||||
seen := map[string]bool{}
|
||||
var zones []string
|
||||
add := func(zone string) {
|
||||
zone = strings.ToLower(strings.TrimSpace(strings.TrimPrefix(zone, "*.")))
|
||||
if zone == "" || zone == "@" || seen[zone] {
|
||||
return
|
||||
}
|
||||
seen[zone] = true
|
||||
zones = append(zones, zone)
|
||||
}
|
||||
add(c.RootDomain)
|
||||
for _, name := range c.RecordNamesList() {
|
||||
fqdn := FQDNFromRecord(c, name)
|
||||
add(fqdn)
|
||||
root, record := ResolveDomainTarget(c.RootDomain, name)
|
||||
add(root)
|
||||
if record != "@" && record != "*" {
|
||||
add(FormatDomainLine(root, record))
|
||||
}
|
||||
}
|
||||
return zones
|
||||
}
|
||||
|
||||
func (c Config) CoversDomain(domain string) bool {
|
||||
domain = strings.ToLower(strings.TrimSpace(strings.TrimPrefix(domain, "*.")))
|
||||
for _, zone := range c.ManagedDNSZones() {
|
||||
if domain == zone || strings.HasSuffix(domain, "."+zone) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package ddns
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseDomainLinesMultiple(t *testing.T) {
|
||||
root, names, err := ParseDomainLines([]string{"s.example.com", "www.example.com", "example.com"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if root != "example.com" || len(names) != 3 || names[0] != "s.example.com" || names[1] != "www.example.com" || names[2] != "example.com" {
|
||||
t.Fatalf("unexpected: root=%s names=%v", root, names)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDomainLinesAllowsMixedRoots(t *testing.T) {
|
||||
root, names, err := ParseDomainLines([]string{"s.example.com", "api.example.com"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if root != "example.com" || len(names) != 2 || names[1] != "api.example.com" {
|
||||
t.Fatalf("unexpected: root=%s names=%v", root, names)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDomainLinesWildcard(t *testing.T) {
|
||||
root, names, err := ParseDomainLines([]string{"*.example.com"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if root != "example.com" || names[0] != "*.example.com" {
|
||||
t.Fatalf("unexpected: root=%s names=%v", root, names)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatDomainLine(t *testing.T) {
|
||||
if FormatDomainLine("example.com", "s") != "s.example.com" {
|
||||
t.Fatal("subdomain format failed")
|
||||
}
|
||||
if FormatDomainLine("example.com", "@") != "example.com" {
|
||||
t.Fatal("apex format failed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRecordNamesRejectsDuplicate(t *testing.T) {
|
||||
_, err := ParseRecordNames([]string{"s", "s"}, "")
|
||||
if err == nil {
|
||||
t.Fatal("expected duplicate error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRecordNamesKeepsDelegatedSubdomainFQDN(t *testing.T) {
|
||||
names, err := ParseRecordNames([]string{"s", "www.sub.example.com"}, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if names[0] != "s" || names[1] != "www.sub.example.com" {
|
||||
t.Fatalf("unexpected names: %v", names)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDomainLinesMixedRootDomains(t *testing.T) {
|
||||
cfg := Config{
|
||||
RootDomain: "example.com",
|
||||
RecordNames: []string{"s", "www.sub.example.com"},
|
||||
}
|
||||
lines := cfg.DomainLines()
|
||||
if len(lines) != 2 || lines[0] != "s.example.com" || lines[1] != "www.sub.example.com" {
|
||||
t.Fatalf("unexpected lines: %v", lines)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFQDNFromRecordDelegatedSubdomainHost(t *testing.T) {
|
||||
cfg := Config{RootDomain: "example.com"}
|
||||
if got := FQDNFromRecord(cfg, "www.sub.example.com"); got != "www.sub.example.com" {
|
||||
t.Fatalf("expected www.sub.example.com, got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatDomainLineMultiLabelHost(t *testing.T) {
|
||||
if got := FormatDomainLine("example.com", "api.nas"); got != "api.nas.example.com" {
|
||||
t.Fatalf("expected api.nas.example.com, got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDomainLinesDeepSubdomain(t *testing.T) {
|
||||
_, names, err := ParseDomainLines([]string{"api.nas.example.com"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if names[0] != "api.nas.example.com" {
|
||||
t.Fatalf("expected full fqdn, got %s", names[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDomainLinesPreservesDeepSubdomain(t *testing.T) {
|
||||
cfg := Config{
|
||||
RootDomain: "example.com",
|
||||
RecordNames: []string{"s.example.com", "api.nas.example.com"},
|
||||
}
|
||||
lines := cfg.DomainLines()
|
||||
if len(lines) != 2 || lines[1] != "api.nas.example.com" {
|
||||
t.Fatalf("unexpected lines: %v", lines)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDomainFormat(t *testing.T) {
|
||||
if err := ValidateDomainFormat("s.example.com"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ValidateDomainFormat("www.sub.example.com"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ValidateDomainFormat("s") == nil {
|
||||
t.Fatal("expected error for short domain")
|
||||
}
|
||||
if ValidateDomainFormat("bad_label-.com") == nil {
|
||||
t.Fatal("expected error for invalid label")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFQDNsFromSaveInput(t *testing.T) {
|
||||
names, err := FQDNsFromSaveInput(SaveInput{
|
||||
Domains: []string{"s.example.com", "www.sub.example.com"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(names) != 2 || names[1] != "www.sub.example.com" {
|
||||
t.Fatalf("unexpected names: %v", names)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,709 @@
|
||||
package ddns
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/fonu/fonu/internal/notify"
|
||||
"github.com/fonu/fonu/internal/publicip"
|
||||
"github.com/fonu/fonu/internal/secret"
|
||||
"github.com/fonu/fonu/internal/settings"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
store *Store
|
||||
settings *settings.Store
|
||||
secretBox *secret.Box
|
||||
providers map[string]Provider
|
||||
logger *slog.Logger
|
||||
notify *notify.Service
|
||||
}
|
||||
|
||||
func NewService(store *Store, settings *settings.Store, secretBox *secret.Box, logger *slog.Logger, notifySvc *notify.Service) *Service {
|
||||
return &Service{
|
||||
store: store,
|
||||
settings: settings,
|
||||
secretBox: secretBox,
|
||||
providers: map[string]Provider{
|
||||
"cloudflare": NewCloudflare(),
|
||||
"dnspod": NewDNSPod(),
|
||||
"alidns": NewAliDNS(),
|
||||
"tencentcloud": NewTencentCloud(),
|
||||
"volcengine": NewVolcengine(),
|
||||
},
|
||||
logger: logger.With("module", "DDNS"),
|
||||
notify: notifySvc,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) List(ctx context.Context) ([]Config, error) {
|
||||
configs, err := s.store.List(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range configs {
|
||||
s.enrichDomainRecords(ctx, &configs[i])
|
||||
sanitizeConfig(&configs[i])
|
||||
}
|
||||
return configs, nil
|
||||
}
|
||||
|
||||
func (s *Service) ListLite(ctx context.Context) ([]Config, error) {
|
||||
configs, err := s.store.List(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range configs {
|
||||
if len(configs[i].DomainRecords) == 0 {
|
||||
configs[i].DomainRecords = BuildDomainRecords(configs[i])
|
||||
}
|
||||
sanitizeConfig(&configs[i])
|
||||
}
|
||||
return configs, nil
|
||||
}
|
||||
|
||||
func (s *Service) Get(ctx context.Context, id int64) (Config, error) {
|
||||
cfg, err := s.store.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
s.enrichDomainRecords(ctx, &cfg)
|
||||
sanitizeConfig(&cfg)
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func (s *Service) CredentialsForDomain(ctx context.Context, rootDomain string) (string, Credentials, error) {
|
||||
cfg, err := s.store.GetByRootDomain(ctx, rootDomain)
|
||||
if err != nil {
|
||||
return "", Credentials{}, err
|
||||
}
|
||||
return s.credentialsForConfig(ctx, cfg)
|
||||
}
|
||||
|
||||
func (s *Service) CredentialsForConfigID(ctx context.Context, id int64) (Config, string, Credentials, error) {
|
||||
cfg, err := s.Get(ctx, id)
|
||||
if err != nil {
|
||||
return Config{}, "", Credentials{}, fmt.Errorf("未找到所选 DNS 配置")
|
||||
}
|
||||
provider, cred, err := s.credentialsForConfig(ctx, cfg)
|
||||
if err != nil {
|
||||
return Config{}, "", Credentials{}, err
|
||||
}
|
||||
return cfg, provider, cred, nil
|
||||
}
|
||||
|
||||
func (s *Service) ConfigForDNSZone(ctx context.Context, dnsZone string) (Config, error) {
|
||||
cfg, err := s.store.GetByRootDomain(ctx, dnsZone)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if err := requireEnabled(cfg); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func (s *Service) ConfigForAnyDomain(ctx context.Context, domain string) (Config, error) {
|
||||
domain = strings.ToLower(strings.TrimSpace(strings.TrimPrefix(domain, "*.")))
|
||||
configs, err := s.store.List(ctx)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
for _, cfg := range configs {
|
||||
if !cfg.Enabled {
|
||||
continue
|
||||
}
|
||||
if cfg.CoversDomain(domain) {
|
||||
return cfg, nil
|
||||
}
|
||||
}
|
||||
return Config{}, fmt.Errorf("未找到域名 %s 的 DDNS 配置", domain)
|
||||
}
|
||||
|
||||
func (s *Service) CredentialsForAnyDomain(ctx context.Context, domain string) (string, Credentials, error) {
|
||||
cfg, err := s.ConfigForAnyDomain(ctx, domain)
|
||||
if err != nil {
|
||||
return "", Credentials{}, fmt.Errorf("请先在 DDNS 页面配置域名 %s 的 DNS 凭证", strings.TrimPrefix(domain, "*."))
|
||||
}
|
||||
return s.credentialsForConfig(ctx, cfg)
|
||||
}
|
||||
|
||||
func requireEnabled(cfg Config) error {
|
||||
if !cfg.Enabled {
|
||||
return fmt.Errorf("DNS 任务 %s 未启用,请先在 DDNS 页面开启", cfg.RootDomain)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) credentialsForConfig(ctx context.Context, cfg Config) (string, Credentials, error) {
|
||||
if err := requireEnabled(cfg); err != nil {
|
||||
return cfg.Provider, Credentials{}, err
|
||||
}
|
||||
cred, err := s.loadCredentialsByID(ctx, cfg.ID)
|
||||
if err != nil {
|
||||
return cfg.Provider, Credentials{}, err
|
||||
}
|
||||
cred.Provider = cfg.Provider
|
||||
return cfg.Provider, cred, nil
|
||||
}
|
||||
|
||||
func (s *Service) Create(ctx context.Context, in SaveInput) (Config, error) {
|
||||
if err := validateSaveInput(in); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if !in.HasCredentialUpdate() {
|
||||
return Config{}, fmt.Errorf("请填写 DNS API 凭证")
|
||||
}
|
||||
tokenEnc, err := s.encryptCredentials(in)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
return s.store.Create(ctx, in, tokenEnc)
|
||||
}
|
||||
|
||||
func (s *Service) Update(ctx context.Context, id int64, in SaveInput) (Config, error) {
|
||||
if err := validateSaveInput(in); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
existing, err := s.store.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
tokenEnc := ""
|
||||
updateToken := in.HasCredentialUpdate()
|
||||
if updateToken {
|
||||
tokenEnc, err = s.encryptCredentials(in)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
} else if !existing.HasToken {
|
||||
return Config{}, fmt.Errorf("请填写 DNS API 凭证")
|
||||
}
|
||||
return s.store.Update(ctx, id, in, tokenEnc, updateToken)
|
||||
}
|
||||
|
||||
func (s *Service) Delete(ctx context.Context, id int64) error {
|
||||
return s.store.Delete(ctx, id)
|
||||
}
|
||||
|
||||
type TestInput struct {
|
||||
ConfigID int64
|
||||
Provider string
|
||||
APIToken string
|
||||
APITokenID string
|
||||
APISecret string
|
||||
}
|
||||
|
||||
func (s *Service) Test(ctx context.Context, in TestInput) error {
|
||||
providerName := strings.TrimSpace(in.Provider)
|
||||
cred := CredentialsFromSave(SaveInput{
|
||||
Provider: providerName,
|
||||
APIToken: in.APIToken,
|
||||
APITokenID: in.APITokenID,
|
||||
APISecret: in.APISecret,
|
||||
})
|
||||
|
||||
if cred.HasValues() {
|
||||
if providerName == "" {
|
||||
providerName = "cloudflare"
|
||||
}
|
||||
cred.Provider = providerName
|
||||
return s.providerFor(providerName).Verify(ctx, cred)
|
||||
}
|
||||
|
||||
if in.ConfigID > 0 {
|
||||
cfg, err := s.store.GetByID(ctx, in.ConfigID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cred, err = s.loadCredentialsByID(ctx, in.ConfigID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cred.Provider = cfg.Provider
|
||||
return s.providerFor(cfg.Provider).Verify(ctx, cred)
|
||||
}
|
||||
|
||||
return fmt.Errorf("请填写 DNS API 凭证")
|
||||
}
|
||||
|
||||
func (s *Service) UpdateNow(ctx context.Context, id int64) (Config, error) {
|
||||
cfg, err := s.store.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if !cfg.Enabled {
|
||||
return Config{}, fmt.Errorf("该 DDNS 配置未启用")
|
||||
}
|
||||
if err := s.runUpdate(ctx, cfg); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
return s.Get(ctx, id)
|
||||
}
|
||||
|
||||
func (s *Service) UpdateAll(ctx context.Context) ([]Config, error) {
|
||||
configs, err := s.store.List(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var lastErr error
|
||||
var failCount int
|
||||
var runCount int
|
||||
for _, cfg := range configs {
|
||||
if !cfg.Enabled {
|
||||
continue
|
||||
}
|
||||
runCount++
|
||||
if err := s.runUpdate(ctx, cfg); err != nil {
|
||||
lastErr = err
|
||||
failCount++
|
||||
s.logger.Error("ddns update failed", "domain", cfg.RootDomain, "error", err.Error())
|
||||
}
|
||||
}
|
||||
list, listErr := s.List(ctx)
|
||||
if listErr != nil {
|
||||
return nil, listErr
|
||||
}
|
||||
if failCount > 0 && failCount == runCount {
|
||||
return list, lastErr
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
func (s *Service) Tick(ctx context.Context) {
|
||||
configs, err := s.store.List(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, cfg := range configs {
|
||||
if !cfg.Enabled {
|
||||
continue
|
||||
}
|
||||
if err := s.runUpdate(ctx, cfg); err != nil {
|
||||
s.logger.Error("ddns update failed", "domain", cfg.RootDomain, "error", err.Error())
|
||||
if s.notify != nil {
|
||||
s.notify.Alert(ctx, notify.EventDDNSFailure, "DDNS 更新失败", cfg.RootDomain+": "+err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// PublicIPs returns the most recently synced public IPs from enabled DDNS configs.
|
||||
func (s *Service) PublicIPs(ctx context.Context) (ipv4, ipv6 string, ok bool) {
|
||||
return s.latestStoredPublicIPs(ctx, true)
|
||||
}
|
||||
|
||||
// LastKnownPublicIPs returns stored public IPs from the most recently updated config,
|
||||
// including paused tasks. Used to avoid live IP detection when DDNS is temporarily disabled.
|
||||
func (s *Service) LastKnownPublicIPs(ctx context.Context) (ipv4, ipv6 string, ok bool) {
|
||||
return s.latestStoredPublicIPs(ctx, false)
|
||||
}
|
||||
|
||||
func (s *Service) latestStoredPublicIPs(ctx context.Context, enabledOnly bool) (ipv4, ipv6 string, ok bool) {
|
||||
configs, err := s.store.List(ctx)
|
||||
if err != nil || len(configs) == 0 {
|
||||
return "", "", false
|
||||
}
|
||||
var latest *Config
|
||||
for _, cfg := range configs {
|
||||
if enabledOnly && !cfg.Enabled {
|
||||
continue
|
||||
}
|
||||
if latest == nil || (cfg.LastUpdatedAt != nil && (latest.LastUpdatedAt == nil || cfg.LastUpdatedAt.After(*latest.LastUpdatedAt))) {
|
||||
latest = &cfg
|
||||
}
|
||||
}
|
||||
if latest == nil {
|
||||
return "", "", false
|
||||
}
|
||||
if latest.LastIPv4 == "" && latest.LastIPv6 == "" {
|
||||
return "", "", false
|
||||
}
|
||||
return latest.LastIPv4, latest.LastIPv6, true
|
||||
}
|
||||
|
||||
func (s *Service) Summary(ctx context.Context) (status string, lastUpdated string, count int) {
|
||||
configs, err := s.store.List(ctx)
|
||||
if err != nil || len(configs) == 0 {
|
||||
return "disabled", "", 0
|
||||
}
|
||||
count = len(configs)
|
||||
status = "ok"
|
||||
hasEnabled := false
|
||||
var latest *Config
|
||||
for _, cfg := range configs {
|
||||
if cfg.Enabled {
|
||||
hasEnabled = true
|
||||
}
|
||||
if latest == nil || (cfg.LastUpdatedAt != nil && (latest.LastUpdatedAt == nil || cfg.LastUpdatedAt.After(*latest.LastUpdatedAt))) {
|
||||
latest = &cfg
|
||||
}
|
||||
if cfg.LastStatus == "error" {
|
||||
status = "error"
|
||||
}
|
||||
}
|
||||
if !hasEnabled {
|
||||
return "disabled", "", count
|
||||
}
|
||||
if latest != nil && latest.LastUpdatedAt != nil {
|
||||
lastUpdated = latest.LastUpdatedAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
if status != "error" && latest != nil && latest.LastStatus != "" {
|
||||
status = latest.LastStatus
|
||||
}
|
||||
return status, lastUpdated, count
|
||||
}
|
||||
|
||||
func sanitizeConfig(cfg *Config) {
|
||||
if !publicip.IsPublicIPv4(cfg.LastIPv4) {
|
||||
cfg.LastIPv4 = ""
|
||||
}
|
||||
if !publicip.IsPublicIPv6(cfg.LastIPv6) {
|
||||
cfg.LastIPv6 = ""
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) enrichDomainRecords(ctx context.Context, cfg *Config) {
|
||||
records := BuildDomainRecords(*cfg)
|
||||
if !cfg.HasToken || len(records) == 0 {
|
||||
cfg.DomainRecords = records
|
||||
return
|
||||
}
|
||||
cred, err := s.loadCredentialsByID(ctx, cfg.ID)
|
||||
if err != nil {
|
||||
cfg.DomainRecords = records
|
||||
return
|
||||
}
|
||||
cred.Provider = cfg.Provider
|
||||
provider := s.providerFor(cfg.Provider)
|
||||
zoneCache := make(map[string]zoneHost)
|
||||
|
||||
for i := range records {
|
||||
prevIPv4 := records[i].IPv4
|
||||
prevIPv6 := records[i].IPv6
|
||||
prevStatus := records[i].Status
|
||||
prevMessage := records[i].Message
|
||||
|
||||
fqdn := FQDNFromRecord(*cfg, records[i].Domain)
|
||||
zone, record, err := s.resolveZone(ctx, provider, cred, fqdn, zoneCache)
|
||||
if err != nil {
|
||||
records[i].Domain = fqdn
|
||||
records[i].Status = "error"
|
||||
records[i].Message = err.Error()
|
||||
continue
|
||||
}
|
||||
records[i].Domain = FormatDomainLine(zone, record)
|
||||
|
||||
if cfg.IPv4Enabled {
|
||||
ip, err := provider.GetRecordIP(ctx, cred, zone, record, "A")
|
||||
switch {
|
||||
case err != nil:
|
||||
records[i].IPv4 = prevIPv4
|
||||
if prevStatus == "" || prevStatus == "unknown" {
|
||||
records[i].Status = "error"
|
||||
records[i].Message = err.Error()
|
||||
}
|
||||
case ip != "":
|
||||
records[i].IPv4 = ip
|
||||
if prevStatus == "" || prevStatus == "unknown" {
|
||||
records[i].Status = "unchanged"
|
||||
records[i].Message = "记录未变化"
|
||||
} else {
|
||||
records[i].Status = prevStatus
|
||||
records[i].Message = prevMessage
|
||||
}
|
||||
default:
|
||||
records[i].IPv4 = prevIPv4
|
||||
}
|
||||
}
|
||||
if cfg.IPv6Enabled {
|
||||
ip, err := provider.GetRecordIP(ctx, cred, zone, record, "AAAA")
|
||||
switch {
|
||||
case err != nil:
|
||||
records[i].IPv6 = prevIPv6
|
||||
if records[i].Status == "" || records[i].Status == "unknown" {
|
||||
records[i].Status = "error"
|
||||
records[i].Message = err.Error()
|
||||
}
|
||||
case ip != "":
|
||||
records[i].IPv6 = ip
|
||||
default:
|
||||
records[i].IPv6 = prevIPv6
|
||||
}
|
||||
}
|
||||
if records[i].Status == "" {
|
||||
records[i].Status = "unknown"
|
||||
}
|
||||
}
|
||||
cfg.DomainRecords = records
|
||||
}
|
||||
|
||||
func (s *Service) runUpdate(ctx context.Context, cfg Config) error {
|
||||
prevIPv4 := cfg.LastIPv4
|
||||
prevIPv6 := cfg.LastIPv6
|
||||
|
||||
cred, err := s.loadCredentialsByID(ctx, cfg.ID)
|
||||
if err != nil {
|
||||
_ = s.store.UpdateStatus(ctx, cfg.ID, cfg.LastIPv4, cfg.LastIPv6, "error", err.Error())
|
||||
return err
|
||||
}
|
||||
cred.Provider = cfg.Provider
|
||||
provider := s.providerFor(cfg.Provider)
|
||||
|
||||
detectedIPv4, detectedIPv6, _ := publicip.Detect(ctx)
|
||||
if detectedIPv4 != "" && !publicip.IsPublicIPv4(detectedIPv4) {
|
||||
detectedIPv4 = ""
|
||||
}
|
||||
if detectedIPv6 != "" && !publicip.IsPublicIPv6(detectedIPv6) {
|
||||
detectedIPv6 = ""
|
||||
}
|
||||
// 自定义 IP 优先:填写后直接作为更新目标(如公网服务器 IP),不再依赖本机出口 IP 检测
|
||||
customIPv4 := strings.TrimSpace(cfg.CustomIPv4)
|
||||
customIPv6 := strings.TrimSpace(cfg.CustomIPv6)
|
||||
if customIPv4 != "" && !publicip.IsPublicIPv4(customIPv4) {
|
||||
msg := "自定义 IPv4 不是公网地址,已拒绝更新"
|
||||
_ = s.store.UpdateStatus(ctx, cfg.ID, cfg.LastIPv4, cfg.LastIPv6, "error", msg)
|
||||
return fmt.Errorf(msg)
|
||||
}
|
||||
if customIPv6 != "" && !publicip.IsPublicIPv6(customIPv6) {
|
||||
msg := "自定义 IPv6 不是公网地址,已拒绝更新"
|
||||
_ = s.store.UpdateStatus(ctx, cfg.ID, cfg.LastIPv4, cfg.LastIPv6, "error", msg)
|
||||
return fmt.Errorf(msg)
|
||||
}
|
||||
if customIPv4 != "" {
|
||||
detectedIPv4 = customIPv4
|
||||
}
|
||||
if customIPv6 != "" {
|
||||
detectedIPv6 = customIPv6
|
||||
}
|
||||
storeIPv4 := cfg.LastIPv4
|
||||
storeIPv6 := cfg.LastIPv6
|
||||
if !publicip.IsPublicIPv4(storeIPv4) {
|
||||
storeIPv4 = ""
|
||||
}
|
||||
if !publicip.IsPublicIPv6(storeIPv6) {
|
||||
storeIPv6 = ""
|
||||
}
|
||||
if cfg.IPv4Enabled && detectedIPv4 != "" {
|
||||
storeIPv4 = detectedIPv4
|
||||
}
|
||||
if cfg.IPv6Enabled && detectedIPv6 != "" {
|
||||
storeIPv6 = detectedIPv6
|
||||
}
|
||||
|
||||
if cfg.IPv4Enabled && detectedIPv4 == "" {
|
||||
msg := "无法获取公网 IPv4,已跳过更新(不会写入内网 IP)"
|
||||
_ = s.store.UpdateStatus(ctx, cfg.ID, storeIPv4, storeIPv6, "error", msg)
|
||||
return fmt.Errorf(msg)
|
||||
}
|
||||
if cfg.IPv6Enabled && detectedIPv6 == "" {
|
||||
msg := "无法获取公网 IPv6,已跳过更新"
|
||||
_ = s.store.UpdateStatus(ctx, cfg.ID, storeIPv4, storeIPv6, "error", msg)
|
||||
return fmt.Errorf(msg)
|
||||
}
|
||||
|
||||
recordNames := cfg.RecordNamesList()
|
||||
domainRecords := make([]DomainRecord, 0, len(recordNames))
|
||||
var lastErr error
|
||||
var errCount, okCount int
|
||||
changed := false
|
||||
zoneCache := make(map[string]zoneHost)
|
||||
|
||||
for _, name := range recordNames {
|
||||
fqdn := FQDNFromRecord(cfg, name)
|
||||
zone, record, zoneErr := s.resolveZone(ctx, provider, cred, fqdn, zoneCache)
|
||||
if zoneErr != nil {
|
||||
dr := DomainRecord{Domain: fqdn, Status: "error", Message: zoneErr.Error()}
|
||||
domainRecords = append(domainRecords, dr)
|
||||
lastErr = zoneErr
|
||||
errCount++
|
||||
continue
|
||||
}
|
||||
fqdn = FormatDomainLine(zone, record)
|
||||
dr := DomainRecord{Domain: fqdn}
|
||||
|
||||
if cfg.IPv4Enabled && detectedIPv4 != "" {
|
||||
current, getErr := provider.GetRecordIP(ctx, cred, zone, record, "A")
|
||||
if getErr != nil {
|
||||
dr.Status = "error"
|
||||
dr.Message = getErr.Error()
|
||||
lastErr = getErr
|
||||
domainRecords = append(domainRecords, dr)
|
||||
continue
|
||||
}
|
||||
dr.IPv4 = current
|
||||
if current == detectedIPv4 {
|
||||
dr.Status = "unchanged"
|
||||
dr.Message = "记录未变化"
|
||||
} else if !publicip.IsPublicIPv4(detectedIPv4) {
|
||||
dr.Status = "error"
|
||||
dr.Message = "检测到内网 IPv4,拒绝更新"
|
||||
lastErr = fmt.Errorf("检测到内网 IPv4,拒绝更新")
|
||||
} else if err := provider.UpdateRecord(ctx, cred, zone, record, "A", detectedIPv4); err != nil {
|
||||
dr.Status = "error"
|
||||
dr.Message = err.Error()
|
||||
lastErr = err
|
||||
} else {
|
||||
dr.IPv4 = detectedIPv4
|
||||
dr.Status = "ok"
|
||||
dr.Message = "已更新"
|
||||
changed = true
|
||||
s.logger.Info("ddns ipv4 updated", "domain", fqdn, "ip", detectedIPv4)
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.IPv6Enabled && detectedIPv6 != "" && dr.Status != "error" {
|
||||
current, getErr := provider.GetRecordIP(ctx, cred, zone, record, "AAAA")
|
||||
if getErr != nil {
|
||||
dr.Status = "error"
|
||||
dr.Message = getErr.Error()
|
||||
lastErr = getErr
|
||||
} else {
|
||||
dr.IPv6 = current
|
||||
if current == detectedIPv6 {
|
||||
if dr.Status == "" {
|
||||
dr.Status = "unchanged"
|
||||
dr.Message = "记录未变化"
|
||||
}
|
||||
} else if !publicip.IsPublicIPv6(detectedIPv6) {
|
||||
dr.Status = "error"
|
||||
dr.Message = "检测到内网 IPv6,拒绝更新"
|
||||
lastErr = fmt.Errorf("检测到内网 IPv6,拒绝更新")
|
||||
} else if err := provider.UpdateRecord(ctx, cred, zone, record, "AAAA", detectedIPv6); err != nil {
|
||||
dr.Status = "error"
|
||||
dr.Message = err.Error()
|
||||
lastErr = err
|
||||
} else {
|
||||
dr.IPv6 = detectedIPv6
|
||||
dr.Status = "ok"
|
||||
dr.Message = "已更新"
|
||||
changed = true
|
||||
s.logger.Info("ddns ipv6 updated", "domain", fqdn, "ip", detectedIPv6)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if dr.Status == "" {
|
||||
dr.Status = "unchanged"
|
||||
dr.Message = "记录未变化"
|
||||
}
|
||||
if dr.Status == "error" {
|
||||
errCount++
|
||||
} else {
|
||||
okCount++
|
||||
}
|
||||
domainRecords = append(domainRecords, dr)
|
||||
}
|
||||
|
||||
overallStatus := "ok"
|
||||
overallError := ""
|
||||
switch {
|
||||
case errCount > 0 && okCount == 0:
|
||||
overallStatus = "error"
|
||||
if lastErr != nil {
|
||||
overallError = lastErr.Error()
|
||||
}
|
||||
case errCount > 0:
|
||||
overallStatus = "warning"
|
||||
overallError = fmt.Sprintf("%d 个域名同步失败,其余正常", errCount)
|
||||
lastErr = nil
|
||||
}
|
||||
if changed {
|
||||
s.logger.Info("ddns records updated", "provider", cfg.Provider)
|
||||
if s.notify != nil {
|
||||
msg := formatDDNSIPChangeMessage(cfg.RootDomain, prevIPv4, prevIPv6, storeIPv4, storeIPv6)
|
||||
s.notify.Alert(ctx, notify.EventDDNSIPChange, "DDNS IP 已变更", msg)
|
||||
}
|
||||
}
|
||||
_ = s.store.UpdateSyncResult(ctx, cfg.ID, storeIPv4, storeIPv6, overallStatus, overallError, domainRecords)
|
||||
return lastErr
|
||||
}
|
||||
|
||||
func formatDDNSIPChangeMessage(domain, prevIPv4, prevIPv6, newIPv4, newIPv6 string) string {
|
||||
var parts []string
|
||||
if newIPv4 != "" && newIPv4 != prevIPv4 {
|
||||
if prevIPv4 != "" {
|
||||
parts = append(parts, fmt.Sprintf("IPv4: %s → %s", prevIPv4, newIPv4))
|
||||
} else {
|
||||
parts = append(parts, fmt.Sprintf("IPv4: %s", newIPv4))
|
||||
}
|
||||
}
|
||||
if newIPv6 != "" && newIPv6 != prevIPv6 {
|
||||
if prevIPv6 != "" {
|
||||
parts = append(parts, fmt.Sprintf("IPv6: %s → %s", prevIPv6, newIPv6))
|
||||
} else {
|
||||
parts = append(parts, fmt.Sprintf("IPv6: %s", newIPv6))
|
||||
}
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return domain
|
||||
}
|
||||
return domain + "\n" + strings.Join(parts, "\n")
|
||||
}
|
||||
|
||||
func (s *Service) encryptCredentials(in SaveInput) (string, error) {
|
||||
cred := CredentialsFromSave(in)
|
||||
if err := cred.Validate(in.Provider); err != nil {
|
||||
return "", err
|
||||
}
|
||||
raw, err := cred.Marshal()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return s.secretBox.Encrypt(string(raw))
|
||||
}
|
||||
|
||||
func (s *Service) loadCredentialsByID(ctx context.Context, id int64) (Credentials, error) {
|
||||
enc, err := s.store.GetTokenByID(ctx, id)
|
||||
if err != nil || enc == "" {
|
||||
return Credentials{}, fmt.Errorf("DNS API 凭证未配置")
|
||||
}
|
||||
raw, err := s.secretBox.Decrypt(enc)
|
||||
if err != nil {
|
||||
return Credentials{}, secret.DecryptHint(err)
|
||||
}
|
||||
return ParseCredentials(raw)
|
||||
}
|
||||
|
||||
func (s *Service) providerFor(name string) Provider {
|
||||
if p, ok := s.providers[name]; ok {
|
||||
return p
|
||||
}
|
||||
return s.providers["cloudflare"]
|
||||
}
|
||||
|
||||
func (s *Service) resolveZone(ctx context.Context, provider Provider, cred Credentials, fqdn string, cache map[string]zoneHost) (zone, host string, err error) {
|
||||
if cache != nil {
|
||||
if item, ok := cache[fqdn]; ok {
|
||||
return item.zone, item.host, item.err
|
||||
}
|
||||
}
|
||||
zone, host, err = ResolveZoneForFQDN(ctx, func(candidate string) (bool, error) {
|
||||
return provider.HasZone(ctx, cred, candidate)
|
||||
}, fqdn)
|
||||
if cache != nil {
|
||||
cache[fqdn] = zoneHost{zone: zone, host: host, err: err}
|
||||
}
|
||||
return zone, host, err
|
||||
}
|
||||
|
||||
type zoneHost struct {
|
||||
zone string
|
||||
host string
|
||||
err error
|
||||
}
|
||||
|
||||
func validateSaveInput(in SaveInput) error {
|
||||
if _, err := FQDNsFromSaveInput(in); err != nil {
|
||||
return err
|
||||
}
|
||||
switch strings.TrimSpace(in.Provider) {
|
||||
case "dnspod", "cloudflare", "alidns", "tencentcloud", "volcengine", "":
|
||||
default:
|
||||
return fmt.Errorf("不支持的 DNS Provider")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
package ddns
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
ID int64 `json:"id"`
|
||||
Provider string `json:"provider"`
|
||||
RootDomain string `json:"root_domain"`
|
||||
RecordName string `json:"record_name"`
|
||||
RecordNames []string `json:"record_names"`
|
||||
IPv4Enabled bool `json:"ipv4_enabled"`
|
||||
IPv6Enabled bool `json:"ipv6_enabled"`
|
||||
Enabled bool `json:"enabled"`
|
||||
HasToken bool `json:"has_token"`
|
||||
CustomIPv4 string `json:"custom_ipv4,omitempty"`
|
||||
CustomIPv6 string `json:"custom_ipv6,omitempty"`
|
||||
LastIPv4 string `json:"last_ipv4"`
|
||||
LastIPv6 string `json:"last_ipv6"`
|
||||
LastStatus string `json:"last_status"`
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
DomainRecords []DomainRecord `json:"domain_records"`
|
||||
LastUpdatedAt *time.Time `json:"last_updated_at,omitempty"`
|
||||
}
|
||||
|
||||
type SaveInput struct {
|
||||
Provider string
|
||||
RootDomain string
|
||||
RecordName string
|
||||
RecordNames []string
|
||||
Domains []string
|
||||
IPv4Enabled bool
|
||||
IPv6Enabled bool
|
||||
Enabled bool
|
||||
CustomIPv4 string
|
||||
CustomIPv6 string
|
||||
APIToken string
|
||||
APITokenID string
|
||||
APISecret string
|
||||
}
|
||||
|
||||
func (in SaveInput) HasCredentialUpdate() bool {
|
||||
return strings.TrimSpace(in.APIToken) != "" ||
|
||||
strings.TrimSpace(in.APITokenID) != "" ||
|
||||
strings.TrimSpace(in.APISecret) != ""
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewStore(db *sql.DB) *Store {
|
||||
return &Store{db: db}
|
||||
}
|
||||
|
||||
func (s *Store) List(ctx context.Context) ([]Config, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT id, provider, root_domain, record_name, COALESCE(record_names, '[]'), ipv4_enabled, ipv6_enabled, enabled,
|
||||
CASE WHEN api_token_enc IS NOT NULL AND api_token_enc != '' THEN 1 ELSE 0 END,
|
||||
COALESCE(last_ipv4, ''), COALESCE(last_ipv6, ''), last_status, COALESCE(last_error, ''),
|
||||
COALESCE(domain_records, '[]'), last_updated_at, COALESCE(custom_ipv4, ''), COALESCE(custom_ipv6, '')
|
||||
FROM ddns_configs ORDER BY id ASC
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var configs []Config
|
||||
for rows.Next() {
|
||||
cfg, err := scanConfig(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
configs = append(configs, cfg)
|
||||
}
|
||||
return configs, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) GetByID(ctx context.Context, id int64) (Config, error) {
|
||||
row := s.db.QueryRowContext(ctx, `
|
||||
SELECT id, provider, root_domain, record_name, COALESCE(record_names, '[]'), ipv4_enabled, ipv6_enabled, enabled,
|
||||
CASE WHEN api_token_enc IS NOT NULL AND api_token_enc != '' THEN 1 ELSE 0 END,
|
||||
COALESCE(last_ipv4, ''), COALESCE(last_ipv6, ''), last_status, COALESCE(last_error, ''),
|
||||
COALESCE(domain_records, '[]'), last_updated_at, COALESCE(custom_ipv4, ''), COALESCE(custom_ipv6, '')
|
||||
FROM ddns_configs WHERE id = ?
|
||||
`, id)
|
||||
cfg, err := scanConfig(row)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Config{}, fmt.Errorf("DDNS 配置不存在")
|
||||
}
|
||||
return cfg, err
|
||||
}
|
||||
|
||||
func (s *Store) GetByRootDomain(ctx context.Context, rootDomain string) (Config, error) {
|
||||
rootDomain = strings.ToLower(strings.TrimSpace(rootDomain))
|
||||
row := s.db.QueryRowContext(ctx, `
|
||||
SELECT id, provider, root_domain, record_name, COALESCE(record_names, '[]'), ipv4_enabled, ipv6_enabled, enabled,
|
||||
CASE WHEN api_token_enc IS NOT NULL AND api_token_enc != '' THEN 1 ELSE 0 END,
|
||||
COALESCE(last_ipv4, ''), COALESCE(last_ipv6, ''), last_status, COALESCE(last_error, ''),
|
||||
COALESCE(domain_records, '[]'), last_updated_at, COALESCE(custom_ipv4, ''), COALESCE(custom_ipv6, '')
|
||||
FROM ddns_configs WHERE root_domain = ? ORDER BY id LIMIT 1
|
||||
`, rootDomain)
|
||||
cfg, err := scanConfig(row)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Config{}, fmt.Errorf("未找到域名 %s 的 DDNS 配置", rootDomain)
|
||||
}
|
||||
return cfg, err
|
||||
}
|
||||
|
||||
func (s *Store) GetTokenByID(ctx context.Context, id int64) (string, error) {
|
||||
var enc string
|
||||
err := s.db.QueryRowContext(ctx, `SELECT COALESCE(api_token_enc, '') FROM ddns_configs WHERE id = ?`, id).Scan(&enc)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", fmt.Errorf("DDNS 配置不存在")
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
return enc, nil
|
||||
}
|
||||
|
||||
func (s *Store) Create(ctx context.Context, in SaveInput, tokenEnc string) (Config, error) {
|
||||
rootDomain, recordName, recordNamesJSON, provider, err := normalizeInput(in)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
res, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO ddns_configs(provider, root_domain, record_name, record_names, ipv4_enabled, ipv6_enabled, enabled, api_token_enc, custom_ipv4, custom_ipv6, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
|
||||
`, provider, rootDomain, recordName, recordNamesJSON, boolInt(in.IPv4Enabled), boolInt(in.IPv6Enabled), boolInt(in.Enabled), tokenEnc, in.CustomIPv4, in.CustomIPv6)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "UNIQUE") {
|
||||
return Config{}, fmt.Errorf("该域名与子域名组合已存在")
|
||||
}
|
||||
return Config{}, err
|
||||
}
|
||||
id, _ := res.LastInsertId()
|
||||
return s.GetByID(ctx, id)
|
||||
}
|
||||
|
||||
func (s *Store) Update(ctx context.Context, id int64, in SaveInput, tokenEnc string, updateToken bool) (Config, error) {
|
||||
rootDomain, recordName, recordNamesJSON, provider, err := normalizeInput(in)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if updateToken {
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
UPDATE ddns_configs
|
||||
SET provider = ?, root_domain = ?, record_name = ?, record_names = ?, ipv4_enabled = ?, ipv6_enabled = ?, enabled = ?,
|
||||
api_token_enc = ?, custom_ipv4 = ?, custom_ipv6 = ?, updated_at = datetime('now')
|
||||
WHERE id = ?
|
||||
`, provider, rootDomain, recordName, recordNamesJSON, boolInt(in.IPv4Enabled), boolInt(in.IPv6Enabled), boolInt(in.Enabled), tokenEnc, in.CustomIPv4, in.CustomIPv6, id)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "UNIQUE") {
|
||||
return Config{}, fmt.Errorf("该域名与子域名组合已存在")
|
||||
}
|
||||
return Config{}, err
|
||||
}
|
||||
} else {
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
UPDATE ddns_configs
|
||||
SET provider = ?, root_domain = ?, record_name = ?, record_names = ?, ipv4_enabled = ?, ipv6_enabled = ?, enabled = ?,
|
||||
custom_ipv4 = ?, custom_ipv6 = ?, updated_at = datetime('now')
|
||||
WHERE id = ?
|
||||
`, provider, rootDomain, recordName, recordNamesJSON, boolInt(in.IPv4Enabled), boolInt(in.IPv6Enabled), boolInt(in.Enabled), in.CustomIPv4, in.CustomIPv6, id)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "UNIQUE") {
|
||||
return Config{}, fmt.Errorf("该域名与子域名组合已存在")
|
||||
}
|
||||
return Config{}, err
|
||||
}
|
||||
}
|
||||
return s.GetByID(ctx, id)
|
||||
}
|
||||
|
||||
func (s *Store) Delete(ctx context.Context, id int64) error {
|
||||
res, err := s.db.ExecContext(ctx, `DELETE FROM ddns_configs WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return fmt.Errorf("DDNS 配置不存在")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) UpdateStatus(ctx context.Context, id int64, ipv4, ipv6, status, lastError string) error {
|
||||
return s.UpdateSyncResult(ctx, id, ipv4, ipv6, status, lastError, nil)
|
||||
}
|
||||
|
||||
func (s *Store) UpdateSyncResult(ctx context.Context, id int64, ipv4, ipv6, status, lastError string, records []DomainRecord) error {
|
||||
domainRecordsJSON := ""
|
||||
if records != nil {
|
||||
domainRecordsJSON = EncodeDomainRecords(records)
|
||||
}
|
||||
if domainRecordsJSON != "" {
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
UPDATE ddns_configs
|
||||
SET last_ipv4 = ?, last_ipv6 = ?, last_status = ?, last_error = ?, domain_records = ?,
|
||||
last_updated_at = datetime('now'), updated_at = datetime('now')
|
||||
WHERE id = ?
|
||||
`, ipv4, ipv6, status, lastError, domainRecordsJSON, id)
|
||||
return err
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
UPDATE ddns_configs
|
||||
SET last_ipv4 = ?, last_ipv6 = ?, last_status = ?, last_error = ?, last_updated_at = datetime('now'), updated_at = datetime('now')
|
||||
WHERE id = ?
|
||||
`, ipv4, ipv6, status, lastError, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func normalizeInput(in SaveInput) (rootDomain, recordName, recordNamesJSON, provider string, err error) {
|
||||
provider = strings.TrimSpace(in.Provider)
|
||||
if provider == "" {
|
||||
provider = "cloudflare"
|
||||
}
|
||||
|
||||
var names []string
|
||||
if len(in.Domains) > 0 {
|
||||
rootDomain, names, err = ParseDomainLines(in.Domains)
|
||||
if err != nil {
|
||||
return "", "", "", "", err
|
||||
}
|
||||
} else {
|
||||
rootDomain = strings.ToLower(strings.TrimSpace(in.RootDomain))
|
||||
if rootDomain == "" {
|
||||
return "", "", "", "", fmt.Errorf("至少需要一个域名")
|
||||
}
|
||||
names, err = ParseRecordNames(in.RecordNames, in.RecordName)
|
||||
if err != nil {
|
||||
return "", "", "", "", err
|
||||
}
|
||||
}
|
||||
return rootDomain, names[0], EncodeRecordNames(names), provider, nil
|
||||
}
|
||||
|
||||
func scanConfig(row interface{ Scan(dest ...any) error }) (Config, error) {
|
||||
var cfg Config
|
||||
var ipv4Enabled, ipv6Enabled, enabled, hasToken int
|
||||
var recordNamesRaw, domainRecordsRaw string
|
||||
var lastUpdated sql.NullString
|
||||
if err := row.Scan(&cfg.ID, &cfg.Provider, &cfg.RootDomain, &cfg.RecordName, &recordNamesRaw, &ipv4Enabled, &ipv6Enabled, &enabled, &hasToken, &cfg.LastIPv4, &cfg.LastIPv6, &cfg.LastStatus, &cfg.LastError, &domainRecordsRaw, &lastUpdated, &cfg.CustomIPv4, &cfg.CustomIPv6); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
cfg.RecordNames = DecodeRecordNames(recordNamesRaw, cfg.RecordName)
|
||||
cfg.DomainRecords = DecodeDomainRecords(domainRecordsRaw)
|
||||
cfg.DomainRecords = BuildDomainRecords(cfg)
|
||||
cfg.IPv4Enabled = ipv4Enabled == 1
|
||||
cfg.IPv6Enabled = ipv6Enabled == 1
|
||||
cfg.Enabled = enabled == 1
|
||||
cfg.HasToken = hasToken == 1
|
||||
if lastUpdated.Valid {
|
||||
t := parseTime(lastUpdated.String)
|
||||
cfg.LastUpdatedAt = &t
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func boolInt(v bool) int {
|
||||
if v {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func parseTime(v string) time.Time {
|
||||
t, err := time.Parse("2006-01-02 15:04:05", v)
|
||||
if err != nil {
|
||||
t, _ = time.Parse(time.RFC3339, v)
|
||||
}
|
||||
return t.UTC()
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package ddns
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common"
|
||||
errorsdk "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common/errors"
|
||||
"github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common/profile"
|
||||
dnspod "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/dnspod/v20210323"
|
||||
)
|
||||
|
||||
type TencentCloud struct{}
|
||||
|
||||
func NewTencentCloud() *TencentCloud { return &TencentCloud{} }
|
||||
|
||||
func (t *TencentCloud) Name() string { return "tencentcloud" }
|
||||
|
||||
func (t *TencentCloud) Verify(ctx context.Context, cred Credentials) error {
|
||||
client, err := t.client(cred)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req := dnspod.NewDescribeDomainListRequest()
|
||||
req.Limit = common.Int64Ptr(1)
|
||||
_, err = client.DescribeDomainList(req)
|
||||
return wrapTencentErr(err)
|
||||
}
|
||||
|
||||
func (t *TencentCloud) HasZone(ctx context.Context, cred Credentials, zone string) (bool, error) {
|
||||
client, err := t.client(cred)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
_, err = t.domainInfo(client, zone)
|
||||
return zoneLookupOK(wrapTencentErr(err))
|
||||
}
|
||||
|
||||
func (t *TencentCloud) GetRecordIP(ctx context.Context, cred Credentials, rootDomain, recordName, recordType string) (string, error) {
|
||||
client, err := t.client(cred)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
zone, err := t.domainInfo(client, rootDomain)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
records, err := t.listRecords(client, zone, tencentSubdomain(recordName), recordType)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, rec := range records {
|
||||
if rec.Value != nil {
|
||||
return *rec.Value, nil
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (t *TencentCloud) UpdateRecord(ctx context.Context, cred Credentials, rootDomain, recordName, recordType, ip string) error {
|
||||
client, err := t.client(cred)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
zone, err := t.domainInfo(client, rootDomain)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
subDomain := tencentSubdomain(recordName)
|
||||
records, err := t.listRecords(client, zone, subDomain, recordType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(records) > 0 && records[0].RecordId != nil {
|
||||
req := dnspod.NewModifyRecordRequest()
|
||||
req.Domain = zone.Name
|
||||
req.DomainId = zone.DomainId
|
||||
req.RecordId = records[0].RecordId
|
||||
req.SubDomain = common.StringPtr(subDomain)
|
||||
req.RecordType = common.StringPtr(recordType)
|
||||
req.RecordLine = common.StringPtr(tencentRecordLine(records[0]))
|
||||
req.Value = common.StringPtr(ip)
|
||||
req.TTL = common.Uint64Ptr(600)
|
||||
_, err = client.ModifyRecord(req)
|
||||
return wrapTencentErr(err)
|
||||
}
|
||||
|
||||
req := dnspod.NewCreateRecordRequest()
|
||||
req.Domain = zone.Name
|
||||
req.DomainId = zone.DomainId
|
||||
req.SubDomain = common.StringPtr(subDomain)
|
||||
req.RecordType = common.StringPtr(recordType)
|
||||
req.RecordLine = common.StringPtr("默认")
|
||||
req.Value = common.StringPtr(ip)
|
||||
req.TTL = common.Uint64Ptr(600)
|
||||
_, err = client.CreateRecord(req)
|
||||
return wrapTencentErr(err)
|
||||
}
|
||||
|
||||
func (t *TencentCloud) client(cred Credentials) (*dnspod.Client, error) {
|
||||
if cred.Token == "" || cred.Secret == "" {
|
||||
return nil, fmt.Errorf("腾讯云 DNS 凭证未配置")
|
||||
}
|
||||
credential := common.NewCredential(cred.Token, cred.Secret)
|
||||
cpf := profile.NewClientProfile()
|
||||
cpf.HttpProfile.Endpoint = "dnspod.tencentcloudapi.com"
|
||||
return dnspod.NewClient(credential, "", cpf)
|
||||
}
|
||||
|
||||
func (t *TencentCloud) domainInfo(client *dnspod.Client, rootDomain string) (*dnspod.DomainListItem, error) {
|
||||
rootDomain = strings.ToLower(strings.TrimSpace(rootDomain))
|
||||
req := dnspod.NewDescribeDomainListRequest()
|
||||
var offset int64
|
||||
for {
|
||||
req.Offset = common.Int64Ptr(offset)
|
||||
resp, err := client.DescribeDomainList(req)
|
||||
if err != nil {
|
||||
return nil, wrapTencentErr(err)
|
||||
}
|
||||
for _, zone := range resp.Response.DomainList {
|
||||
if zone.Name != nil && strings.EqualFold(*zone.Name, rootDomain) {
|
||||
return zone, nil
|
||||
}
|
||||
if zone.Punycode != nil && strings.EqualFold(*zone.Punycode, rootDomain) {
|
||||
return zone, nil
|
||||
}
|
||||
}
|
||||
total := int64(0)
|
||||
if resp.Response.DomainCountInfo != nil && resp.Response.DomainCountInfo.AllTotal != nil {
|
||||
total = int64(*resp.Response.DomainCountInfo.AllTotal)
|
||||
}
|
||||
offset += int64(len(resp.Response.DomainList))
|
||||
if offset >= total || len(resp.Response.DomainList) == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("未找到域名 %s 对应的腾讯云 DNS 域名", rootDomain)
|
||||
}
|
||||
|
||||
func (t *TencentCloud) listRecords(client *dnspod.Client, zone *dnspod.DomainListItem, subDomain, recordType string) ([]*dnspod.RecordListItem, error) {
|
||||
req := dnspod.NewDescribeRecordListRequest()
|
||||
req.Domain = zone.Name
|
||||
req.DomainId = zone.DomainId
|
||||
req.Subdomain = common.StringPtr(subDomain)
|
||||
req.RecordType = common.StringPtr(recordType)
|
||||
resp, err := client.DescribeRecordList(req)
|
||||
if err != nil {
|
||||
var sdkErr *errorsdk.TencentCloudSDKError
|
||||
if errors.As(err, &sdkErr) && sdkErr.Code == dnspod.RESOURCENOTFOUND_NODATAOFRECORD {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, wrapTencentErr(err)
|
||||
}
|
||||
if resp.Response == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return resp.Response.RecordList, nil
|
||||
}
|
||||
|
||||
func tencentSubdomain(recordName string) string {
|
||||
sub := subDomainForRecord(recordName)
|
||||
if sub == "@" {
|
||||
return "@"
|
||||
}
|
||||
return sub
|
||||
}
|
||||
|
||||
func tencentRecordLine(rec *dnspod.RecordListItem) string {
|
||||
if rec != nil && rec.Line != nil && *rec.Line != "" {
|
||||
return *rec.Line
|
||||
}
|
||||
return "默认"
|
||||
}
|
||||
|
||||
func wrapTencentErr(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
var sdkErr *errorsdk.TencentCloudSDKError
|
||||
if errors.As(err, &sdkErr) {
|
||||
return fmt.Errorf("腾讯云 DNS API 错误:%s", sdkErr.Message)
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package ddns
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestTencentSubdomain(t *testing.T) {
|
||||
if tencentSubdomain("@") != "@" {
|
||||
t.Fatal("expected @")
|
||||
}
|
||||
if tencentSubdomain("www") != "www" {
|
||||
t.Fatal("expected www")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialsTencentCloud(t *testing.T) {
|
||||
cred := CredentialsFromSave(SaveInput{
|
||||
Provider: "tencentcloud",
|
||||
APIToken: "AKIDxxx",
|
||||
APISecret: "secret",
|
||||
})
|
||||
if cred.Provider != "tencentcloud" || !cred.HasValues() {
|
||||
t.Fatal("expected tencentcloud credentials")
|
||||
}
|
||||
if err := cred.Validate("tencentcloud"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
package ddns
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/volcengine/volcengine-go-sdk/service/dns"
|
||||
"github.com/volcengine/volcengine-go-sdk/volcengine"
|
||||
"github.com/volcengine/volcengine-go-sdk/volcengine/credentials"
|
||||
"github.com/volcengine/volcengine-go-sdk/volcengine/session"
|
||||
"github.com/volcengine/volcengine-go-sdk/volcengine/volcengineerr"
|
||||
)
|
||||
|
||||
const volcengineDNSRegion = "cn-beijing"
|
||||
|
||||
type Volcengine struct{}
|
||||
|
||||
func NewVolcengine() *Volcengine { return &Volcengine{} }
|
||||
|
||||
func (v *Volcengine) Name() string { return "volcengine" }
|
||||
|
||||
func (v *Volcengine) Verify(ctx context.Context, cred Credentials) error {
|
||||
client, err := v.client(cred)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = client.ListZonesWithContext(ctx, &dns.ListZonesInput{
|
||||
PageNumber: volcengine.Int32(1),
|
||||
PageSize: volcengine.Int32(1),
|
||||
})
|
||||
return wrapVolcengineErr(err)
|
||||
}
|
||||
|
||||
func (v *Volcengine) HasZone(ctx context.Context, cred Credentials, zone string) (bool, error) {
|
||||
client, err := v.client(cred)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
_, err = v.zoneID(ctx, client, zone)
|
||||
return zoneLookupOK(err)
|
||||
}
|
||||
|
||||
func (v *Volcengine) GetRecordIP(ctx context.Context, cred Credentials, rootDomain, recordName, recordType string) (string, error) {
|
||||
client, err := v.client(cred)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
zid, err := v.zoneID(ctx, client, rootDomain)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
rec, err := v.findRecord(ctx, client, zid, volcengineHost(recordName), recordType)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if rec != nil && rec.Value != nil {
|
||||
return *rec.Value, nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (v *Volcengine) UpdateRecord(ctx context.Context, cred Credentials, rootDomain, recordName, recordType, ip string) error {
|
||||
client, err := v.client(cred)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
zid, err := v.zoneID(ctx, client, rootDomain)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
host := volcengineHost(recordName)
|
||||
existing, err := v.findRecord(ctx, client, zid, host, recordType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if existing != nil && existing.RecordID != nil {
|
||||
line := volcengineLine(existing)
|
||||
_, err = client.UpdateRecordWithContext(ctx, &dns.UpdateRecordInput{
|
||||
RecordID: existing.RecordID,
|
||||
Host: volcengine.String(host),
|
||||
Line: volcengine.String(line),
|
||||
Type: volcengine.String(recordType),
|
||||
Value: volcengine.String(ip),
|
||||
TTL: volcengine.Int32(600),
|
||||
})
|
||||
return wrapVolcengineErr(err)
|
||||
}
|
||||
|
||||
_, err = client.CreateRecordWithContext(ctx, &dns.CreateRecordInput{
|
||||
ZID: volcengine.Int64(zid),
|
||||
Host: volcengine.String(host),
|
||||
Type: volcengine.String(recordType),
|
||||
Value: volcengine.String(ip),
|
||||
Line: volcengine.String("default"),
|
||||
TTL: volcengine.Int32(600),
|
||||
})
|
||||
return wrapVolcengineErr(err)
|
||||
}
|
||||
|
||||
func (v *Volcengine) client(cred Credentials) (*dns.DNS, error) {
|
||||
if cred.Token == "" || cred.Secret == "" {
|
||||
return nil, fmt.Errorf("火山引擎 DNS 凭证未配置")
|
||||
}
|
||||
cfg := volcengine.NewConfig().
|
||||
WithRegion(volcengineDNSRegion).
|
||||
WithCredentials(credentials.NewStaticCredentials(cred.Token, cred.Secret, ""))
|
||||
sess, err := session.NewSession(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return dns.New(sess), nil
|
||||
}
|
||||
|
||||
func (v *Volcengine) zoneID(ctx context.Context, client *dns.DNS, rootDomain string) (int64, error) {
|
||||
rootDomain = strings.ToLower(strings.TrimSpace(rootDomain))
|
||||
var page int32 = 1
|
||||
const pageSize int32 = 100
|
||||
for {
|
||||
out, err := client.ListZonesWithContext(ctx, &dns.ListZonesInput{
|
||||
Key: volcengine.String(rootDomain),
|
||||
PageNumber: volcengine.Int32(page),
|
||||
PageSize: volcengine.Int32(pageSize),
|
||||
})
|
||||
if err != nil {
|
||||
return 0, wrapVolcengineErr(err)
|
||||
}
|
||||
for _, zone := range out.Zones {
|
||||
if zone.ZoneName != nil && strings.EqualFold(*zone.ZoneName, rootDomain) && zone.ZID != nil {
|
||||
return int64(*zone.ZID), nil
|
||||
}
|
||||
}
|
||||
total := int32(0)
|
||||
if out.Total != nil {
|
||||
total = *out.Total
|
||||
}
|
||||
if len(out.Zones) == 0 || page*pageSize >= total {
|
||||
break
|
||||
}
|
||||
page++
|
||||
}
|
||||
return 0, fmt.Errorf("未找到域名 %s 对应的火山引擎 DNS 域名", rootDomain)
|
||||
}
|
||||
|
||||
func (v *Volcengine) findRecord(ctx context.Context, client *dns.DNS, zid int64, host, recordType string) (*dns.RecordForListRecordsOutput, error) {
|
||||
var page int32 = 1
|
||||
const pageSize int32 = 100
|
||||
for {
|
||||
out, err := client.ListRecordsWithContext(ctx, &dns.ListRecordsInput{
|
||||
ZID: volcengine.Int64(zid),
|
||||
Host: volcengine.String(host),
|
||||
Type: volcengine.String(recordType),
|
||||
PageNumber: volcengine.Int32(page),
|
||||
PageSize: volcengine.Int32(pageSize),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, wrapVolcengineErr(err)
|
||||
}
|
||||
for _, rec := range out.Records {
|
||||
if rec.Host != nil && *rec.Host == host && rec.Type != nil && strings.EqualFold(*rec.Type, recordType) {
|
||||
return rec, nil
|
||||
}
|
||||
}
|
||||
total := int32(0)
|
||||
if out.TotalCount != nil {
|
||||
total = *out.TotalCount
|
||||
}
|
||||
if len(out.Records) == 0 || page*pageSize >= total {
|
||||
break
|
||||
}
|
||||
page++
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func volcengineHost(recordName string) string {
|
||||
return subDomainForRecord(recordName)
|
||||
}
|
||||
|
||||
func volcengineLine(rec *dns.RecordForListRecordsOutput) string {
|
||||
if rec != nil && rec.Line != nil && strings.TrimSpace(*rec.Line) != "" {
|
||||
return *rec.Line
|
||||
}
|
||||
return "default"
|
||||
}
|
||||
|
||||
func wrapVolcengineErr(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
var sdkErr volcengineerr.RequestFailure
|
||||
if errors.As(err, &sdkErr) {
|
||||
return fmt.Errorf("火山引擎 DNS API 错误:%s", sdkErr.Message())
|
||||
}
|
||||
var apiErr volcengineerr.Error
|
||||
if errors.As(err, &apiErr) {
|
||||
return fmt.Errorf("火山引擎 DNS API 错误:%s", apiErr.Message())
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package ddns
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/volcengine/volcengine-go-sdk/service/dns"
|
||||
"github.com/volcengine/volcengine-go-sdk/volcengine"
|
||||
)
|
||||
|
||||
// UpsertTXTRecord creates or updates a TXT record for ACME DNS-01.
|
||||
func (v *Volcengine) UpsertTXTRecord(ctx context.Context, cred Credentials, fqdn, value string) error {
|
||||
zone, host, err := ResolveZoneForFQDN(ctx, func(candidate string) (bool, error) {
|
||||
return v.HasZone(ctx, cred, candidate)
|
||||
}, fqdn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return v.UpdateRecord(ctx, cred, zone, host, "TXT", value)
|
||||
}
|
||||
|
||||
// DeleteTXTRecord removes TXT records matching value at fqdn (ACME cleanup).
|
||||
func (v *Volcengine) DeleteTXTRecord(ctx context.Context, cred Credentials, fqdn, value string) error {
|
||||
zone, host, err := ResolveZoneForFQDN(ctx, func(candidate string) (bool, error) {
|
||||
return v.HasZone(ctx, cred, candidate)
|
||||
}, fqdn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return v.deleteTXTRecords(ctx, cred, zone, host, value)
|
||||
}
|
||||
|
||||
func (v *Volcengine) deleteTXTRecords(ctx context.Context, cred Credentials, rootDomain, recordName, value string) error {
|
||||
client, err := v.client(cred)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
zid, err := v.zoneID(ctx, client, rootDomain)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
host := volcengineHost(recordName)
|
||||
records, err := v.listRecords(ctx, client, zid, host, "TXT")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, rec := range records {
|
||||
if rec.Value == nil || strings.TrimSpace(*rec.Value) != strings.TrimSpace(value) {
|
||||
continue
|
||||
}
|
||||
if rec.RecordID == nil {
|
||||
continue
|
||||
}
|
||||
if _, err := client.DeleteRecordWithContext(ctx, &dns.DeleteRecordInput{
|
||||
RecordID: rec.RecordID,
|
||||
}); err != nil {
|
||||
return wrapVolcengineErr(err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v *Volcengine) listRecords(ctx context.Context, client *dns.DNS, zid int64, host, recordType string) ([]*dns.RecordForListRecordsOutput, error) {
|
||||
var out []*dns.RecordForListRecordsOutput
|
||||
var page int32 = 1
|
||||
const pageSize int32 = 100
|
||||
for {
|
||||
resp, err := client.ListRecordsWithContext(ctx, &dns.ListRecordsInput{
|
||||
ZID: volcengine.Int64(zid),
|
||||
Host: volcengine.String(host),
|
||||
Type: volcengine.String(recordType),
|
||||
PageNumber: volcengine.Int32(page),
|
||||
PageSize: volcengine.Int32(pageSize),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, wrapVolcengineErr(err)
|
||||
}
|
||||
out = append(out, resp.Records...)
|
||||
total := int32(0)
|
||||
if resp.TotalCount != nil {
|
||||
total = *resp.TotalCount
|
||||
}
|
||||
if len(resp.Records) == 0 || page*pageSize >= total {
|
||||
break
|
||||
}
|
||||
page++
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package ddns
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResolveZoneForACMEChallenge(t *testing.T) {
|
||||
zones := map[string]bool{
|
||||
"example.com": true,
|
||||
}
|
||||
zone, host, err := ResolveZoneForFQDN(context.Background(), func(candidate string) (bool, error) {
|
||||
return zones[candidate], nil
|
||||
}, "_acme-challenge.example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if zone != "example.com" || host != "_acme-challenge" {
|
||||
t.Fatalf("unexpected zone=%s host=%s", zone, host)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package ddns
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type zoneChecker func(zone string) (bool, error)
|
||||
|
||||
// ResolveZoneForFQDN finds the longest DNS zone suffix that exists at the provider.
|
||||
// For delegated zones (e.g. sub.example.com hosted separately), it prefers sub.example.com over example.com.
|
||||
func ResolveZoneForFQDN(ctx context.Context, check zoneChecker, fqdn string) (zone, host string, err error) {
|
||||
fqdn = strings.ToLower(strings.TrimSpace(strings.TrimSuffix(fqdn, ".")))
|
||||
if fqdn == "" {
|
||||
return "", "", fmt.Errorf("域名不能为空")
|
||||
}
|
||||
|
||||
if strings.HasPrefix(fqdn, "*.") {
|
||||
zone, host, err := ResolveZoneForFQDN(ctx, check, strings.TrimPrefix(fqdn, "*."))
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if host == "@" {
|
||||
return zone, "*", nil
|
||||
}
|
||||
return zone, "*." + host, nil
|
||||
}
|
||||
|
||||
parts := strings.Split(fqdn, ".")
|
||||
if len(parts) < 2 {
|
||||
return "", "", fmt.Errorf("域名格式无效: %s", fqdn)
|
||||
}
|
||||
|
||||
for i := 0; i < len(parts)-1; i++ {
|
||||
candidate := strings.Join(parts[i:], ".")
|
||||
ok, err := check(candidate)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
host := "@"
|
||||
if i > 0 {
|
||||
host = strings.Join(parts[:i], ".")
|
||||
}
|
||||
return candidate, host, nil
|
||||
}
|
||||
return "", "", fmt.Errorf("未在 DNS 服务商中找到域名 %s 对应的 Zone", fqdn)
|
||||
}
|
||||
|
||||
func FQDNFromRecord(cfg Config, name string) string {
|
||||
name = strings.TrimSpace(strings.ToLower(name))
|
||||
if name == "" {
|
||||
return strings.ToLower(strings.TrimSpace(cfg.RootDomain))
|
||||
}
|
||||
if strings.Contains(name, ".") || strings.HasPrefix(name, "*.") {
|
||||
root, record, err := parseDomainLine(name)
|
||||
if err == nil {
|
||||
return FormatDomainLine(root, record)
|
||||
}
|
||||
return strings.TrimSuffix(name, ".")
|
||||
}
|
||||
return FormatDomainLine(cfg.RootDomain, name)
|
||||
}
|
||||
|
||||
func isZoneNotFound(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
msg := strings.ToLower(err.Error())
|
||||
return strings.Contains(msg, "未找到") ||
|
||||
strings.Contains(msg, "not found") ||
|
||||
strings.Contains(msg, "resourcenotfound") ||
|
||||
strings.Contains(msg, "invaliddomain")
|
||||
}
|
||||
|
||||
func zoneLookupOK(err error) (bool, error) {
|
||||
if err == nil {
|
||||
return true, nil
|
||||
}
|
||||
if isZoneNotFound(err) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package ddns
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResolveZoneForFQDNDelegatedSubdomain(t *testing.T) {
|
||||
zones := map[string]bool{
|
||||
"sub.example.com": true,
|
||||
}
|
||||
zone, host, err := ResolveZoneForFQDN(context.Background(), func(candidate string) (bool, error) {
|
||||
return zones[candidate], nil
|
||||
}, "sub.example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if zone != "sub.example.com" || host != "@" {
|
||||
t.Fatalf("unexpected zone=%s host=%s", zone, host)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveZoneForFQDNPrefersLongestZone(t *testing.T) {
|
||||
zones := map[string]bool{
|
||||
"sub.example.com": true,
|
||||
"example.com": true,
|
||||
}
|
||||
zone, host, err := ResolveZoneForFQDN(context.Background(), func(candidate string) (bool, error) {
|
||||
return zones[candidate], nil
|
||||
}, "www.sub.example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if zone != "sub.example.com" || host != "www" {
|
||||
t.Fatalf("unexpected zone=%s host=%s", zone, host)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveZoneForFQDNClassicSubdomain(t *testing.T) {
|
||||
zones := map[string]bool{
|
||||
"example.com": true,
|
||||
}
|
||||
zone, host, err := ResolveZoneForFQDN(context.Background(), func(candidate string) (bool, error) {
|
||||
return zones[candidate], nil
|
||||
}, "s.example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if zone != "example.com" || host != "s" {
|
||||
t.Fatalf("unexpected zone=%s host=%s", zone, host)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveZoneForFQDNWildcard(t *testing.T) {
|
||||
zones := map[string]bool{
|
||||
"sub.example.com": true,
|
||||
}
|
||||
zone, host, err := ResolveZoneForFQDN(context.Background(), func(candidate string) (bool, error) {
|
||||
return zones[candidate], nil
|
||||
}, "*.sub.example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if zone != "sub.example.com" || host != "*" {
|
||||
t.Fatalf("unexpected zone=%s host=%s", zone, host)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFQDNFromRecordStoredFQDN(t *testing.T) {
|
||||
cfg := Config{RootDomain: "example.com"}
|
||||
if FQDNFromRecord(cfg, "sub.example.com") != "sub.example.com" {
|
||||
t.Fatal("expected stored fqdn preserved")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user