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:
2026-09-23 09:40:59 +08:00
commit 278f8267b7
333 changed files with 53562 additions and 0 deletions
+244
View File
@@ -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"`
}
+164
View File
@@ -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"`
}
+96
View File
@@ -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
}
+29
View File
@@ -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")
}
}
+105
View File
@@ -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
}
+25
View File
@@ -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
}
+63
View File
@@ -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
}
+20
View File
@@ -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")
}
}
+11
View File
@@ -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)
}
+319
View File
@@ -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
}
+131
View File
@@ -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)
}
}
+709
View File
@@ -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
}
+281
View File
@@ -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()
}
+186
View File
@@ -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
}
+26
View File
@@ -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)
}
}
+201
View File
@@ -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
}
+89
View File
@@ -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
}
+21
View File
@@ -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)
}
}
+87
View File
@@ -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
}
+74
View File
@@ -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")
}
}